Skip to main content

Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs

Mockito 5 mocks final classes by default. This tutorial runs every feature on JDK 25 and shows the real output: the inline mock maker, the JDK agent warning, BDD style, ArgumentCaptor, strict stubs errors explained, mockStatic and mockConstruction.

You write when(repo.find(1)).thenReturn(...), run the test, and Mockito fails it with UnnecessaryStubbingException — for a stub you are sure you need. Or you try to mock a final class, read a Stack Overflow answer from 2019 telling you to add a mockito-inline dependency, and wonder if that still applies. This page walks through what Mockito 5 actually does, from the smallest working test to mocking static methods and constructors, with the real output of every behaviour from a runnable project. Mockito is a library that creates fake versions of your classes (“mocks”) so a test can run one class without its collaborators. You tell a mock what to return (“stubbing”), run the code, and then ask the mock what it was called with (“verifying”). That is the whole idea; everything below is the details that decide whether your tests are trustworthy.
Tested with: Mockito 5.24.0 (mockito-core and mockito-junit-jupiter), JUnit 6.1.3, JDK 25.0.5 (Temurin), Maven 3.9.11, Surefire 3.5.6. Versions taken from the Maven Central directory index on 2026-10-24. Every transcript in this post is a file committed in the java-testing-lab/mockito project and is regenerated by one script, scripts/run-all.sh.
This post is for the person meeting Mockito’s deeper features for the first time. It is also meant to be useful if you already use Mockito daily: each section ends with the sharper edges, tucked into collapsible blocks so the main thread stays readable. If you are testing a Spring Boot application rather than plain classes, the companion posts on JUnit 6 with AssertJ, Mockito and Testcontainers and @MockitoBean and WireMock cover the integration level.

Mockito 5 can mock final classes without any extra setup

Before Mockito 5, mocking worked by generating a subclass of your class at runtime. A final class cannot be subclassed, so it could not be mocked, and the usual advice was to add the separate mockito-inline artifact. Mockito 5 made the “inline” mock maker the default, so that advice is now obsolete. The inline approach does not subclass anything: it instruments the real class so that calls pass through Mockito’s hook first.
Subclass mock maker (Mockito 1-4 default)PriceCalculatorgenerated subclass“final” class: nothing to extendInline mock maker (Mockito 5 default)PriceCalculatorbytecode hooksame class, instrumentedmock.getClass() == real classThe mock is the real class with a hook in front of its methods, so final no longer blocks it.
The left panel is the old model, where a mock is a different, generated class. The right panel is the Mockito 5 model, where the mock is the real class with a hook in front of its methods. The practical consequence is in the last line of the output below: mock.getClass() equals the real class. The class under test here is deliberately hostile: PriceCalculator.java is final and its method is final too. This test is MockMakerTest.java:
PriceCalculator calc = mock(PriceCalculator.class);
t.line("PriceCalculator is final : %s", java.lang.reflect.Modifier.isFinal(PriceCalculator.class.getModifiers()));
t.line("mock class name           : %s", calc.getClass().getName());
t.line("mock class == real class  : %s", calc.getClass() == PriceCalculator.class);

when(calc.applyDiscount(1000, 10)).thenReturn(42L);
t.line("stubbed final method      : applyDiscount(1000, 10) = %d", calc.applyDiscount(1000, 10));
t.line("unstubbed final method    : applyDiscount(1000, 20) = %d", calc.applyDiscount(1000, 20));
assertEquals(42L, calc.applyDiscount(1000, 10));
assertEquals(0L, calc.applyDiscount(1000, 20));
The committed output of that test, from output/01-mock-maker.txt:
default MockMaker : org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker
PriceCalculator is final : true
mock class name           : lab.mockito.PriceCalculator
mock class == real class  : true
stubbed final method      : applyDiscount(1000, 10) = 42
unstubbed final method    : applyDiscount(1000, 20) = 0
mocked a record           : true
Notice the unstubbed call returned 0: a mock’s methods return a default (0, false, null, an empty collection) until you stub them.
Intermediate: what “inline” means and what it costs Per the Mockito Javadoc, the inline mock maker uses the Java instrumentation API (through Byte Buddy) to rewrite the bytecode of the class being mocked. That is why it can mock final classes, final methods and, as the test above shows, records. It is also the only mock maker that supports mockStatic and mockConstruction, which the later sections use. The cost is that Mockito needs the JVM’s permission to instrument classes, which is what the next section is about. If you need the old subclass behaviour, the Javadoc linked above describes how to switch mock makers; I did not exercise that path in this project.

The JDK warns when Mockito attaches itself — here is the fix

The first time you run an inline-mocking test on a recent JDK without any setup, the console fills with warnings. Nothing is broken; the JDK is telling you that something is loading a Java agent into the running program, and that this will eventually be refused. Mockito does exactly that to enable the inline mock maker, because nobody told it to load earlier. I captured it by running the one test above with the no-agent profile in the project’s pom.xml (output file output/14-dynamic-agent-warning.txt):
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (<local-repo>/byte-buddy-agent-1.17.7.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
The fix is to hand Mockito to the JVM as an agent when it starts, which is what the project’s default argLine does. The relevant lines of the project’s pom.xml:
<mockito.agent>${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar</mockito.agent>
<surefire.argLine>-javaagent:${mockito.agent} -Xshare:off</surefire.argLine>
...
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
<configuration>
  <argLine>${surefire.argLine}</argLine>
</configuration>
With that in place the same tests run with no warning. The path assumes the default local repository layout; the version in the property is the one the dependency uses, so the two cannot drift apart.
One more place this matters: an IDE run configuration does not read Surefire’s argLine. If a test passes in Maven but your IDE prints the warning (or fails on a future JDK), add the same -javaagent flag to the IDE’s VM options.

The smallest useful test: mock, stub, call, verify

A mock is only useful if you can say what it returns and check what happened to it. The BDD style of Mockito (BDDMockito) names those steps given, when and then, which reads like the test’s own documentation. The class under test is CheckoutService.java: it asks a payment gateway whether it is available, discounts the price, charges, and writes an audit entry. The full test is BddStyleTest.java:
// given
given(gateway.isAvailable()).willReturn(true);
given(calculator.applyDiscount(1000, 10)).willReturn(900L);
given(gateway.charge("acc-1", 900)).willReturn(new Receipt("r-1", "acc-1", 900, true));
CheckoutService svc = new CheckoutService(gateway, audit, calculator);

// when
Receipt receipt = svc.checkout("acc-1", 1000, 10);

// then
assertTrue(receipt.approved());
then(gateway).should().charge("acc-1", 900);
then(audit).should().record("CHECKOUT", "acc-1:900");
then(audit).shouldHaveNoMoreInteractions();
InOrder order = Mockito.inOrder(gateway, audit);
order.verify(gateway).charge("acc-1", 900);
order.verify(audit).record("CHECKOUT", "acc-1:900");
Output from output/08-bdd-style.txt:
receipt approved           : true
then(gateway).should()     : charge("acc-1", 900) ok
then(audit).should()       : record("CHECKOUT", "acc-1:900") ok
shouldHaveNoMoreInteractions on audit : ok
InOrder gateway.charge -> audit.record : ok
Three verification tools are in that test. then(x).should() checks a call happened. shouldHaveNoMoreInteractions() fails if the mock was touched in any way you did not verify. InOrder checks that two calls happened in a given sequence, across several mocks. The opposite check — that something did not happen — matters as much. When the gateway is unavailable, the service must not charge anybody, and it must write a rejection audit entry. Output of the second test, output/09-bdd-throw-and-never.txt:
exception              : gateway unavailable
gateway.charge never called : ok
audit recorded CHECKOUT_REJECTED : ok
Intermediate: choosing what to verify Verify behaviour that matters to a caller or a collaborator — money moved, an event published — and assert on return values for everything else. Verifying every call to every mock couples the test to the implementation and makes harmless refactors fail tests. shouldHaveNoMoreInteractions() is useful on a collaborator whose side effects are the contract (an audit log), and noisy on one that is merely consulted (a price calculator).

ArgumentCaptor shows you what your code really sent

Stubbing and verifying with exact values works when you know the values. Sometimes the interesting value is computed inside the code under test — here, the discounted amount that is charged. An ArgumentCaptor records the argument at call time so the test can inspect it afterwards. The idea in two steps: pass captor.capture() where an argument matcher goes inside verify(...), then read the recorded value with getValue() (the last call) or getAllValues() (every call, in order). The code is from CaptorTest.java:
@Captor ArgumentCaptor<String> detail;
@Captor ArgumentCaptor<Long> cents;

svc.checkout("acc-9", 1000, 10);
svc.checkout("acc-7", 500, 0);

verify(gateway, times(2)).charge(anyString(), cents.capture());
t.line("charged amounts    : %s", cents.getAllValues());
assertEquals(List.of(900L, 500L), cents.getAllValues());
t.line("last captured      : %d", cents.getValue());

verify(audit, times(2)).record(eq("CHECKOUT"), detail.capture());
t.line("audit details      : %s", detail.getAllValues());
assertEquals(List.of("acc-9:900", "acc-7:500"), detail.getAllValues());
Output from output/06-argument-captor.txt:
charged amounts    : [900, 500]
last captured      : 500
audit details      : [acc-9:900, acc-7:500]
Both calls to charge are captured in order, so the test proves the first customer was charged 900 (1000 minus 10%) and the second 500. With the JUnit extension, @Captor builds the captor for you; without it you create one with ArgumentCaptor.forClass(...). The error you meet when you capture in the wrong place is worth recognising. Calling getValue() on a captor that never captured anything produces this (from output/07-captor-empty.txt):
org.mockito.exceptions.base.MockitoException: 
No argument value was captured!
You might have forgotten to use argument.capture() in verify()...
Capture only inside verify. The exception text says it itself: “it is recommended to use capture() only with verify()”. The message above lists the usual cause (capture() was never reached in a verify) and a second one: capture() used in stubbing, where the stubbed method was never called. Keep captors in verify and the confusion goes away.
Intermediate: captors versus argument matchers A matcher such as argThat(a -> a.cents() > 0) answers yes/no during the call; a captor lets you assert in the test body with your normal assertion library, which usually gives a better failure message when several fields are wrong. Prefer a captor when you assert more than one thing about the same argument, and a matcher when the check is a single condition.

Strict stubs: why Mockito fails tests that look fine

With @ExtendWith(MockitoExtension.class), Mockito runs in strict stubs mode by default. It is the feature behind most “why did my test fail?” moments, so it is worth understanding as a pair of rules rather than as magic.
when(…).thenReturn(…)code under test calls mockdoes a stub match these arguments?yes: stub ismarked usedno, same method:different file?PotentialStubbingProblemat the end: any stub never used -> UnnecessaryStubbingExceptionTwo checks: one at call time for argument mismatches, one at the end for stubs nobody used.
The diagram shows two separate checks. One happens at call time and catches an argument mismatch (you stubbed with 900, the code called with 1000). The other happens when the test ends and catches stubs that no code ever used. Both exist for the same reason: a stub nobody uses is dead code in the test, and a mismatch is usually a bug in the test or the production code. To catch these errors inside a test and record them, the project uses the MockitoSession API with strict stubs, which behaves like the extension. First the unused-stub check, from StrictnessTest.java:
MockitoSession session = strict();
PaymentGateway gateway = mock(PaymentGateway.class);
when(gateway.isAvailable()).thenReturn(true);          // used below
when(gateway.charge("acc-1", 100)).thenReturn(null);   // never used
gateway.isAvailable();

UnnecessaryStubbingException e = assertThrows(UnnecessaryStubbingException.class, session::finishMocking);
The message, from output/10-unnecessary-stubbing.txt:
UnnecessaryStubbingException:
  Unnecessary stubbings detected.
  Clean & maintainable test code requires zero unnecessary code.
  Following stubbings are unnecessary (click to navigate to relevant line of code):
    1. -> at lab.mockito.StrictnessTest.unusedStubIsReportedWhenTheSessionEnds(StrictnessTest.java:33)
  Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
The message points at the exact line of the stub nobody used. The second check is the argument mismatch. The test stubs charge("acc-1", 900), then runs the service with a 0% discount so it calls charge("acc-1", 1000):
when(gateway.charge("acc-1", 900)).thenReturn(new Receipt("r", "acc-1", 900, true));
CheckoutService svc = new CheckoutService(gateway, mock(AuditLog.class), new PriceCalculator());

// 1000 cents with 0% discount reaches charge("acc-1", 1000) inside CheckoutService.java,
// a different source file from this test: Mockito flags the argument mismatch.
PotentialStubbingProblem e = assertThrows(PotentialStubbingProblem.class, () -> svc.checkout("acc-1", 1000, 0));
Output from output/11-potential-stubbing-problem.txt:
PotentialStubbingProblem:
  Strict stubbing argument mismatch. Please check:
   - this invocation of 'charge' method:
      paymentGateway.charge("acc-1", 1000L);
      -> at lab.mockito.CheckoutService.checkout(CheckoutService.java:20)
   - has following stubbing(s) with different arguments:
      1. paymentGateway.charge("acc-1", 900L); stubbed with: [Returns: Receipt[id=r, account=acc-1, cents=900, approved=true]]
        -> at lab.mockito.StrictnessTest.sameMethodDifferentArgumentsIsAPotentialStubbingProblem(StrictnessTest.java:48)
  Typically, stubbing argument mismatch indicates user mistake when writing tests.
  Mockito fails early so that you can debug potential problem easily.
  However, there are legit scenarios when this exception generates false negative signal:
    - stubbing the same method multiple times using 'given().will()' or 'when().then()' API
      Please use 'will().given()' or 'doReturn().when()' API for stubbing.
    - stubbed method is intentionally invoked with different arguments by code under test
      Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT).
  For more information see javadoc for PotentialStubbingProblem class.
Read the message top to bottom: the call that happened, where it happened (CheckoutService.java:20), and the stub it nearly matched, with the line in the test where you wrote it. That is nearly always enough to see the mistake.
The mismatch check only fires across files. I expected the first version of my test, which called gateway.charge("acc-1", 1000) directly from the test class, to throw. It did not. Mockito skips the mismatch error when the stub and the invocation come from the same source file — presumably so test code that calls its own mocks does not raise false alarms, though I found this by running it, not in the documentation. The transcript from output/11b-same-file-mismatch-not-flagged.txt shows it:

charge("acc-1", 1000) from the test file returns : null (no PotentialStubbingProblem)
So a test that exercises a mock directly never gets the mismatch protection; only tests that go through the real code under test do. That is another reason to test behaviour through the class under test rather than poking the mock.
Intermediate: the three ways to relax strictness, and when each is honest Per stub. lenient().when(...) exempts one stub from both checks. This is the right tool for a stub in a shared @BeforeEach that only some tests use. The session test confirms it: an unused lenient stub does not fail, and a mismatching call on a lenient stub does not throw.
lenient().when(gateway.charge("acc-1", 100)).thenReturn(new Receipt("r", "acc-1", 100, true));
lenient().when(gateway.isAvailable()).thenReturn(true);
Per class. The extension reads @MockitoSettings:
@ExtendWith(MockitoExtension.class)            // strict stubs by default
@MockitoSettings(strictness = Strictness.LENIENT)  // opt out for the whole class (use sparingly)
Not at all. A mock created by plain Mockito.mock(...), outside the extension or a session, has no strictness checks at all; a mismatching call just returns the default value. From output/13-no-session-no-checks.txt:
mismatching call on a plain mock returns : null (no strictness error)
Loosening a whole class hides real dead stubs. If the only reason you are tempted is one noisy shared stub, mark that one stub lenient instead.

mockStatic replaces a static method for the length of a try block

Static methods have no object to substitute, so before Mockito 3.4 the options were to wrap them in an instance or reach for PowerMock. mockStatic (which needs the inline mock maker from the first section) gives you a scope: inside a try block the static method is stubbed, outside it is the real thing again. The code under test is OrderService.java, which calls Ids.newOrderId() (random each time) — the kind of dependency that makes a test non-repeatable. The test, from StaticMockingTest.java:
try (MockedStatic<Ids> ids = mockStatic(Ids.class)) {
    ids.when(Ids::newOrderId).thenReturn("ORD-FIXED");
    String placed = new OrderService().place("alice");
    t.line("inside scope : place(\"alice\") = %s", placed);
    assertEquals("ORD-FIXED@alice", placed);

    ids.verify(Ids::newOrderId);
    ids.verify(Ids::newOrderId, times(1));
Output from output/02-mock-static.txt:
before scope : id starts with ORD- : true
inside scope : place("alice") = ORD-FIXED@alice
inside scope : unstubbed Ids.nowMillis() = 0 (default answer, not the clock)
after scope  : id starts with ORD- and is not FIXED : true
Thread A (the test)real Idstry (mockStatic(Ids.class)) { … }real IdsThread B (any other)real Ids the whole timeA static mock is registered per thread and removed when the try block closes.Work done on another thread inside the block (a pool, @Async) sees the real method.
The diagram shows the two properties that cause real bugs. The mock only exists inside the block, which is why the try-with-resources form is not optional: forget to close it and later tests see the mocked method. And the mock is bound to the thread that created it. The transcript from output/03-static-thread-local.txt confirms it:
test thread sees  : ORD-FIXED
other thread sees : real id, not FIXED = true
The second property bites when the code under test hands work to another thread: an executor, @Async, a parallel stream. That work sees the real static method. The third property is a guard against forgetting to close: registering the same class twice on one thread fails, from output/04-static-already-registered.txt:
MockitoException:
  For lab.mockito.Ids, static mocking is already registered in the current thread
  
  To create a new mock, the existing static mock registration must be deregistered
An unstubbed static returns a default, not the real value. In the output above, Ids.nowMillis() returned 0 inside the block because only newOrderId was stubbed. If you want the real method for everything else, pass CALLS_REAL_METHODS as the default answer to mockStatic; output/04b-static-calls-real-methods.txt shows the clock staying real while newOrderId is stubbed.
Intermediate: what you cannot mock statically Some core classes are refused outright. Trying mockStatic(System.class) fails immediately; this is the whole message, from output/04c-static-mock-system.txt:
MockitoException:
  It is not possible to mock static methods of java.lang.System to avoid interfering with class loading what leads to infinite loops
The usual substitute for the clock is not a static mock at all: inject a java.time.Clock and pass a fixed one in the test. Reach for mockStatic for code you do not own or cannot change, not as a first choice for your own code.

mockConstruction intercepts new inside the class under test

The last substitution trick handles a class that builds its own collaborator with new, leaving no seam for injection. NotificationService.java is exactly that:
package lab.mockito;

/** Builds its own EmailClient with `new` - no injection point, so only mockConstruction can intercept it. */
public class NotificationService {
    public boolean welcome(String user) {
        EmailClient client = new EmailClient("smtp.example.com");
        return client.send(user + "@example.com", "Welcome, " + user);
    }
}
The EmailClient constructor in the demo throws, to stand in for a real network connection. mockConstruction makes every new EmailClient(...) inside the block return a mock, and hands you a callback to configure each one as it is built. From ConstructionMockingTest.java:
try (MockedConstruction<EmailClient> constructed = mockConstruction(EmailClient.class,
        (mock, context) -> {
            t.line("constructor args  : %s", context.arguments());
            t.line("construction #    : %d", context.getCount());
            when(mock.send(anyString(), anyString())).thenReturn(true);
        })) {
    boolean sent = new NotificationService().welcome("bob");
    t.line("welcome(\"bob\")    : %s", sent);
    t.line("instances built   : %d", constructed.constructed().size());
    assertTrue(sent);
    assertEquals(1, constructed.constructed().size());
    verify(constructed.constructed().get(0)).send("[email protected]", "Welcome, bob");
Output from output/05-mock-construction.txt:
no interception  : real SMTP connection attempted to smtp.example.com
constructor args  : [smtp.example.com]
construction #    : 1
welcome("bob")    : true
instances built   : 1
verified          : send("[email protected]", "Welcome, bob")
The first line shows the problem the technique solves: without it, the real constructor ran and threw. Inside the block, the callback received the constructor arguments ([smtp.example.com]), a running count of how many instances have been built, and the new mock to stub. Afterwards, constructed() lists every instance, so you can verify what the code did with it.
Intermediate: scope and the limits you will hit Like mockStatic, the interception is per thread and ends when the block closes. A mock built by mockConstruction is created without running the real constructor body, which is why the throwing constructor above is harmless. The mocks only cover classes constructed with new in code running on the same thread; an object created by a framework on another thread is not intercepted.

Should you even mock statics and constructors?

Treat mockStatic and mockConstruction as tools for code you cannot change. Every one of them exists because a class reached for a global or built its own collaborator. For code you own, the change that improves the design — inject a Clock, an ID generator, an EmailClient — also makes the test shorter, thread-safe and independent of the mock maker. The two features are a good fit for legacy code, third-party static utilities, and for pinning behaviour before a refactor. They are a poor fit as the default way to write new tests.

The same goes for strictness. Strict stubs found a wrong stub in the demo within one run. Teams that switch it off to silence a noisy suite lose that signal. Fix or remove the stub instead.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.