Skip to main content

Top 30 JUnit 6 and Mockito Interview Questions (2026)

30 JUnit 6 and Mockito 5 interview questions answered with tests that were actually run on JUnit 6.1.3, Mockito 5.24.0 and JDK 25: lifecycle, extensions, parameterized tests, mocks vs spies, verify, static mocking and flaky tests, each with captured output.

The interview question is rarely “what does @BeforeEach do”. It is “how many instances of this class exist while these two tests run?” or “why did my verify pass but my test still fail?” — and a memorised definition does not survive the follow-up. This page answers 30 JUnit 6 and Mockito questions the other way round: each answer is a small test that was run, and the output shown under it is the file the test wrote. JUnit is the framework that finds and runs your tests; Mockito replaces a collaborator (a database, a payment gateway) with a stand-in you control. Interviews for Java roles tend to move from JUnit’s lifecycle, through extensions and parameterized tests, to Mockito’s mocks, spies and static mocking, and end on flaky tests. The questions below follow that order. Each one is a collapsible block: read the short answer, open the code and output only when you want to check it, and follow the link to the longer ankurm.com article on that topic.
Tested with: JUnit 6.1.3 (Platform and Jupiter, the latest release on Maven Central on 2026-10-26; 6.0.0 went GA on 2025-09-30 with a Java 17 baseline), Mockito 5.24.0 (mockito-core and mockito-junit-jupiter), Maven Surefire 3.6.0, JDK 25.0.5 (Temurin LTS), Maven 3.9.11. Versions read from maven-metadata.xml on Maven Central. All code is in java-interview-lab/junit-mockito; scripts/run-all.sh regenerates every output/qNN-*.txt file quoted below, and two consecutive runs of the tests produced identical files.
What was and was not run. Every output block is a committed file written by a test or script. Where an answer also states something from the JUnit or Mockito release notes or user guide that this module does not execute, the sentence says so (“release-notes-sourced”, “documented, not executed”). The places where running the code surprised me are collected in “Six answers where the code disagreed with the folklore” near the end.

The pieces: Platform, Jupiter and what changed in 6

Before any annotation, interviewers check that you know what you are running. JUnit 6 is three modules that finally share a version number, and the Java 17 baseline is the fact most likely to come up in a migration conversation.
Maven / Gradle / IDEasks to run testsJUnit Platform Launcherfinds engines, runs themJupiter engine@Test, extensionsVintage engineJUnit 3/4 tests, deprecated in 6All three modules now share one version number. This module resolved: junit-jupiter 6.1.3.In this repository only the Jupiter engine is on the classpath, so only it appears in the Q1 output.
The diagram shows who calls whom. Your build tool never runs @Test methods itself; it hands a request to the Launcher, which asks each engine on the classpath to discover and run what it recognises. That is why the same test can be run from Maven, Gradle or an IDE unchanged, and why Q1’s output lists engines at all.
Q1. What are the JUnit Platform, Jupiter and Vintage, and how do they relate? Three pieces share the JUnit name. The Platform is the foundation: the Launcher API that build tools and IDEs call, and the TestEngine interface that anything able to run tests implements. Jupiter is the programming model you write against (@Test, @BeforeEach, the extension model) and the engine that runs it. Vintage is an engine that runs old JUnit 3 and 4 tests on the same Platform. Since JUnit 6.0 all three carry the same version number; in JUnit 5 the Platform was 1.x while Jupiter and Vintage were 5.x, which is why people used to ask “which version of the Platform goes with Jupiter 5.10?”. Test code, from A_JUnitFundamentalsTest.java:
@Test
void q01_platformJupiterVintage() {
    List<String> engines = new ArrayList<>();
    for (TestEngine e : ServiceLoader.load(TestEngine.class)) {
        engines.add(e.getId() + " " + e.getVersion().orElse("?"));
    }
    p("TestEngines found on the classpath (ServiceLoader<TestEngine>):");
    engines.forEach(e -> p("  %s", e));
    p("Jupiter API version  : %s", org.junit.jupiter.api.Test.class.getPackage().getImplementationVersion());
    p("Platform launcher    : %s", org.junit.platform.launcher.Launcher.class.getPackage().getImplementationVersion());
    p("Platform engine API  : %s", TestEngine.class.getPackage().getImplementationVersion());
    p("junit-vintage engine : %s", engines.stream().anyMatch(e -> e.startsWith("junit-vintage")) ? "present" : "not on the classpath");
    assertEquals(1, engines.size());
    assertTrue(engines.get(0).startsWith("junit-jupiter 6."));
    assertEquals(org.junit.jupiter.api.Test.class.getPackage().getImplementationVersion(),
            org.junit.platform.launcher.Launcher.class.getPackage().getImplementationVersion());
}
Captured output, from q01-platform-jupiter-vintage.txt:
TestEngines found on the classpath (ServiceLoader<TestEngine>):
  junit-jupiter 6.1.3
Jupiter API version  : 6.1.3
Platform launcher    : 6.1.3
Platform engine API  : 6.1.3
junit-vintage engine : not on the classpath
Why it matters in practice. A test class is only run if an engine that understands it is on the classpath. This module has no Vintage engine, so the output lists exactly one engine. The 6.0.0 release notes also say the Vintage engine is now deprecated and reports an INFO-level discovery issue when it finds a JUnit 4 test class (release-notes-sourced; not executed here).
Go deeper: JUnit 6 architecture: Platform, engines, launchers · How the test engine works · JUnit user guide
Q2. What changed from JUnit 5 to JUnit 6 that interviewers actually ask about? Two things are verifiable in thirty seconds, and they are the ones to lead with: the Java baseline is 17 (JUnit 5 compiled to Java 8) and removed APIs fail to compile. The test below reads the class-file version of org.junit.jupiter.api.Test from the 6.1.3 jar and asks for MethodOrderer$Alphanumeric, which existed in 5.14.4 and is gone. The script compares the two real jars side by side. Test code, from A_JUnitFundamentalsTest.java:
@Test
void q02_removedAndBaseline() throws Exception {
    int major;
    try (InputStream in = org.junit.jupiter.api.Test.class.getResourceAsStream("Test.class")) {
        byte[] head = in.readNBytes(8);
        major = ((head[6] & 0xff) << 8) | (head[7] & 0xff);
    }
    p("class-file major version of org.junit.jupiter.api.Test : %d (Java %d)", major, major - 44);
    String removed = "org.junit.jupiter.api.MethodOrderer$Alphanumeric";
    try {
        Class.forName(removed);
        p("%s : still present", removed);
    } catch (ClassNotFoundException e) {
        p("Class.forName(\"%s\") -> ClassNotFoundException", removed);
    }
    assertEquals(61, major);
    assertThrows(ClassNotFoundException.class, () -> Class.forName(removed));
    p("(the 5.14.4 versus 6.1.3 jar comparison is scripts/compare-5-vs-6.sh -> q02-jar-comparison.txt)");
}
Captured output, from q02-junit6-baseline-and-removals.txt:
class-file major version of org.junit.jupiter.api.Test : 61 (Java 17)
Class.forName("org.junit.jupiter.api.MethodOrderer$Alphanumeric") -> ClassNotFoundException
(the 5.14.4 versus 6.1.3 jar comparison is scripts/compare-5-vs-6.sh -> q02-jar-comparison.txt)
Jar comparison, produced by compare-5-vs-6.sh (class-file major version 52 is Java 8, 61 is Java 17):
junit-jupiter-api 5.14.4 : class-file major version 52 (Java 8), MethodOrderer$Alphanumeric entries in jar: 1
junit-jupiter-api 6.1.3 : class-file major version 61 (Java 17), MethodOrderer$Alphanumeric entries in jar: 0
source of this output: q02-jar-comparison.txt. The rest of the list below comes from the 6.0.0 release notes and is release-notes-sourced, not executed here, apart from the display-name change you can see yourself in Q13:
Change in 6.0.0What to say in an interviewSource
Java 17 minimum; Kotlin 2.2 minimumBuilds on Java 8 or 11 cannot load the jarsExecuted (Java), release notes (Kotlin)
One version for Platform, Jupiter, VintageNo more 1.x/5.x pairing tableExecuted (Q1)
MethodOrderer.Alphanumeric removedUse MethodOrderer.MethodNameExecuted (Q6)
junit-platform-runner removed; junit-platform-jfr folded into the launcher; junit-platform-suite-commons merged into junit-platform-suiteThe JUnit 4 @RunWith(JUnitPlatform.class) runner is goneRelease notes
Surefire and Failsafe below 3.0.0 unsupportedUpgrade the plugin before the frameworkRelease notes
Vintage engine deprecated; junit-jupiter-migrationsupport deprecatedPlan to port JUnit 4 testsRelease notes
@CsvSource/@CsvFileSource parsed by FastCSV; lineSeparator removedMalformed CSV may now fail with different exceptionsRelease notes
Parameterized display names quote stringsSnapshot-style assertions on display names breakExecuted (Q13, Q14)
JRE.JAVA_8 to JAVA_16 deprecated; JRE range conditions default their minimum to JAVA_17Conditions naming old JREs need updatingRelease notes
JSpecify nullability annotations; cancellation API and --fail-fast; Kotlin suspend test methods; deterministic @Nested orderNew capabilities, not migration blockersRelease notes
Go deeper: JUnit 5 to 6 migration guide · JUnit 6 vs JUnit 5 · 15 common upgrade problems · JUnit 6 deep dive

Lifecycle and ordering: what runs when, on which instance

Most “weird test” stories are lifecycle stories: state that was supposed to be fresh, or supposed to be shared, and was not. The default is the safe one.
@BeforeAllonce, staticrepeated for every @Test method, each with a brand-new instancenew Test()instance #n@BeforeEach@Test method@AfterEach@AfterAllonce, staticBecause the instance is new every time, a field changed in one test is back to its initial value in the next.That is the design: it stops tests leaking state into each other (Q3). PER_CLASS removes this protection (Q4).
Read the dashed box as a loop: the whole of new Test(), @BeforeEach, the test and @AfterEach repeats for every method, while @BeforeAll and @AfterAll sit outside it. The questions below prove each part with the real Launcher, so the order you read is the order that ran.
Q3. In what order do lifecycle methods run, and how many test instances are created? One new instance of the test class per test method. That is the default (PER_METHOD) and the thing interviewers are really probing. The order is: @BeforeAll once (static), then for each test a fresh instance, @BeforeEach, the test, @AfterEach, and finally @AfterAll once. The fixture below numbers its instances; the output shows two tests getting two different instances, so state left in a field never reaches the next test. Test code, from LifecycleFixtures.java:
public static class PerMethod {
    public static int instances;
    final int id = ++instances;

    @BeforeAll
    static void beforeAll() {
        Log.add("@BeforeAll (static)");
    }

    @BeforeEach
    void beforeEach() {
        Log.add("@BeforeEach on instance #" + id);
    }

    @Test
    void first() {
        Log.add("  test first() on instance #" + id);
    }

    @Test
    void second() {
        Log.add("  test second() on instance #" + id);
    }

    @AfterEach
    void afterEach() {
        Log.add("@AfterEach on instance #" + id);
    }

    @AfterAll
    static void afterAll() {
        Log.add("@AfterAll (static)");
    }
}
Captured output, from q03-lifecycle-order.txt:
@BeforeAll (static)
@BeforeEach on instance #1
  test second() on instance #1
@AfterEach on instance #1
@BeforeEach on instance #2
  test first() on instance #2
@AfterEach on instance #2
@AfterAll (static)
Trap. Look at the order of the two tests: second() ran before first(). Declaration order is not execution order (Q6).
Go deeper: JUnit 6 test lifecycle explained · @BeforeEach and @AfterEach guide · @BeforeAll and @AfterAll
Q4. What does @TestInstance(PER_CLASS) change, and when would you use it? It creates one instance for the whole class. Two consequences: @BeforeAll and @AfterAll may be ordinary (non-static) methods, so they can use instance fields and injected parameters; and fields keep their values between tests. The output shows instance #1 serving both tests with the counter going 1 then 2. Test code, from LifecycleFixtures.java:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public static class PerClass {
    public static int instances;
    final int id = ++instances;
    int counter;

    @BeforeAll
    void beforeAll() {
        Log.add("@BeforeAll (non-static) on instance #" + id);
    }

    @Test
    void first() {
        counter++;
        Log.add("first(): instance #" + id + ", counter=" + counter);
    }

    @Test
    void second() {
        counter++;
        Log.add("second(): instance #" + id + ", counter=" + counter);
    }
}
Captured output, from q04-per-class-lifecycle.txt:
@BeforeAll (non-static) on instance #1
second(): instance #1, counter=1
first(): instance #1, counter=2
Trap. You have traded isolation for convenience. A test that passes only because an earlier test left a field set is the root of the flaky-test problem in Q29. Use PER_CLASS for expensive read-only setup (a client, a parsed fixture), not for state the tests mutate. The default can also be changed project-wide with the documented junit.jupiter.testinstance.lifecycle.default configuration parameter.
Go deeper: @TestInstance, @TestMethodOrder and @Timeout · Lifecycle explained
Q5. How do @Nested classes interact with the outer class’s lifecycle methods? A @Nested class is a non-static inner class that gets its own tests but shares the outer class’s context. For a test inside it, the outer @BeforeEach runs first, then the inner one, and the @AfterEach methods unwind in reverse. That is what makes nested classes useful for “given a logged-in user” style grouping: the outer class builds the common world, each inner class adds one condition. Test code, from LifecycleFixtures.java:
public static class Outer {
    @BeforeEach
    void outerBefore() {
        Log.add("outer @BeforeEach");
    }

    @AfterEach
    void outerAfter() {
        Log.add("outer @AfterEach");
    }

    @Test
    void outerTest() {
        Log.add("  outerTest()");
    }

    @Nested
    class Inner {
        @BeforeEach
        void innerBefore() {
            Log.add("inner @BeforeEach");
        }

        @AfterEach
        void innerAfter() {
            Log.add("inner @AfterEach");
        }

        @Test
        void innerTest() {
            Log.add("  innerTest()");
        }
    }
}
Captured output, from q05-nested-lifecycle.txt:
outer @BeforeEach
  outerTest()
outer @AfterEach
outer @BeforeEach
inner @BeforeEach
  innerTest()
inner @AfterEach
outer @AfterEach
The first three lines are the outer test running alone with only the outer callbacks; the next five are the inner test, with outer @BeforeEach immediately before inner @BeforeEach. According to the 6.0.0 release notes, @Nested class order is now deterministic and @TestMethodOrder is inherited by nested classes (release-notes-sourced, not executed). Go deeper: Structuring tests with @Nested · @DisplayName, @Nested, @Tag
Q6. Is test method execution order guaranteed? How do you control it? No test should depend on order, but the framework does define one: by default it is deterministic but intentionally non-obvious (the user guide’s wording). It is not declaration order and not alphabetical. The fixture declares zebra, apple, mango, banana; the default run gave banana, apple, mango, zebra. Choose an orderer when you need one: MethodOrderer.MethodName, DisplayName, OrderAnnotation or Random, either per class with @TestMethodOrder or project-wide with the configuration parameter used below. Test code, from A_JUnitFundamentalsTest.java (it launches the fixture LifecycleFixtures.Ordering):
@Test
void q06_methodOrder() {
    Map<String, String> byName = Map.of("junit.jupiter.testmethod.order.default",
            org.junit.jupiter.api.MethodOrderer.MethodName.class.getName());
    Map<String, String> byDisplay = Map.of("junit.jupiter.testmethod.order.default",
            org.junit.jupiter.api.MethodOrderer.DisplayName.class.getName());
    Run dflt = Run.of(LifecycleFixtures.Ordering.class);
    Run name = Run.of(LifecycleFixtures.Ordering.class, byName);
    Run disp = Run.of(LifecycleFixtures.Ordering.class, byDisplay);
    p("declared order in the source     : zebra(), apple(), mango(), banana()");
    p("default (no orderer configured)  : %s", names(dflt));
    p("MethodOrderer.MethodName         : %s", names(name));
    p("MethodOrderer.DisplayName        : %s", names(disp));
    assertEquals("[apple(), banana(), mango(), zebra()]", names(name));
    assertEquals(4, dflt.events.size());
    assertNotEquals("[zebra(), apple(), mango(), banana()]", names(name));
}
Captured output, from q06-method-order.txt:
declared order in the source     : zebra(), apple(), mango(), banana()
default (no orderer configured)  : [banana(), apple(), mango(), zebra()]
MethodOrderer.MethodName         : [apple(), banana(), mango(), zebra()]
MethodOrderer.DisplayName        : [apple(), banana(), mango(), zebra()]
Trap. MethodOrderer.Alphanumeric, which older tutorials still use, no longer exists in JUnit 6 (Q2). Replace it with MethodName; the code will not compile otherwise.
Go deeper: @TestMethodOrder in depth · User guide: test execution order

Assertions, timeouts and assumptions

These three decide whether a test is red, green or skipped. Interviewers like them because the wrong choice produces a build that is quietly lying.
Q7. What do assertAll and assertThrows give you over plain assertions? A plain assertEquals stops the test at the first failure, so you fix one problem, rerun, and meet the next. assertAll runs every executable and reports all failures together in one MultipleFailuresError; use it when checking several properties of one result. assertThrows runs code, fails if nothing (or the wrong type) is thrown, and returns the exception so you can assert on its message. Test code, from A_JUnitFundamentalsTest.java:
@Test
void q07_groupedAssertions() {
    MultipleFailuresError e = assertThrows(MultipleFailuresError.class, () -> assertAll("order",
            () -> assertEquals(1, 2, "quantity"),
            () -> assertEquals("A", "B", "code"),
            () -> assertTrue(true)));
    p("assertAll reported %d failures from 3 assertions:", e.getFailures().size());
    p("%s", e.getMessage());
    IllegalStateException ise = assertThrows(IllegalStateException.class, () -> {
        throw new IllegalStateException("boom");
    });
    p("assertThrows returns the exception: getMessage() = %s", ise.getMessage());
    assertEquals(2, e.getFailures().size());
}
Captured output, from q07-assert-all-and-assert-throws.txt:
assertAll reported 2 failures from 3 assertions:
order (2 failures)
	org.opentest4j.AssertionFailedError: quantity ==> expected: <1> but was: <2>
	org.opentest4j.AssertionFailedError: code ==> expected: <A> but was: <B>
assertThrows returns the exception: getMessage() = boom
Go deeper: JUnit 6 assertions: all methods explained · Writing your first clean test
Q8. What is the difference between assertTimeout and assertTimeoutPreemptively? assertTimeout runs your code on the test thread, lets it finish, and only then compares the elapsed time, so a hung call hangs the test. assertTimeoutPreemptively runs it on another thread and abandons it when time is up. The output proves both halves: the first reports the test thread as the runner, the second does not, and the two failure messages differ. Test code, from A_JUnitFundamentalsTest.java:
@Test
void q08_timeoutVsPreemptive() {
    String test = Thread.currentThread().getName();
    String[] seenBy = new String[2];
    org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofSeconds(1),
            () -> seenBy[0] = Thread.currentThread().getName());
    org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(1),
            () -> seenBy[1] = Thread.currentThread().getName());
    p("assertTimeout            runs the code on the test thread : %s", seenBy[0].equals(test));
    p("assertTimeoutPreemptively runs the code on the test thread : %s", seenBy[1].equals(test));

    var slow1 = assertThrows(org.opentest4j.AssertionFailedError.class,
            () -> org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(50),
                    () -> Thread.sleep(200)));
    String m1 = slow1.getMessage();
    p("assertTimeout failure message (trimmed at ' by '): %s", m1.substring(0, m1.indexOf(" by ")));
    var slow2 = assertThrows(org.opentest4j.AssertionFailedError.class,
            () -> org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(50),
                    () -> Thread.sleep(2000)));
    p("assertTimeoutPreemptively failure message: %s", slow2.getMessage());
    assertTrue(seenBy[0].equals(test));
    assertFalse(seenBy[1].equals(test));
}
Captured output, from q08-timeout-vs-preemptive.txt:
assertTimeout            runs the code on the test thread : true
assertTimeoutPreemptively runs the code on the test thread : false
assertTimeout failure message (trimmed at ' by '): execution exceeded timeout of 50 ms
assertTimeoutPreemptively failure message: execution timed out after 50 ms
Trap. Because the preemptive variant leaves the test thread, anything bound to that thread is invisible to the code: thread-local security or transaction context, and Mockito’s mockStatic (Q25). The real assertTimeout failure message ends with the overrun in milliseconds, which varies from run to run, so the transcript cuts it at by .
Go deeper: @Timeout · Assertions guide
Q9. What is the difference between a failed assertion and a failed assumption? A failed assertion means the code is wrong: the test is reported FAILED and the build goes red. A failed assumption (assumeTrue, assumingThat) means the test is not applicable here: it is ABORTED, which build tools show as skipped, and the build stays green. The fixture contains one of each; the transcript comes from the real Launcher. Test code, from LifecycleFixtures.java:
public static class Outcomes {
    @Test
    void passes() {
        assertEquals(2, 1 + 1);
    }

    @Test
    void assertionFails() {
        assertEquals(3, 1 + 1);
    }

    @Test
    void assumptionFails() {
        assumeTrue(false, "not on this machine");
        throw new AssertionError("never reached");
    }
}
Captured output, from q09-assertion-vs-assumption.txt:
assertionFails() -> FAILED
assumptionFails() -> ABORTED
passes() -> SUCCESSFUL
Trap. Assumptions are for environment (“needs Docker”), never for business conditions. An assumption that quietly fails on CI turns a real test into a permanent skip, and nobody is told.
Go deeper: Assumptions and conditional execution · Conditional execution

Extensions: how JUnit lets you hook into everything

Almost every integration you meet (Mockito, Spring, Testcontainers, temp directories, parameter injection) is an extension. Knowing the callback order explains why a resource appears “too early” or “too late”.
BeforeAllCallback (once, before the class)BeforeEachCallbackyour @BeforeEach methodBeforeTestExecutionCallbackthe test bodyAfterTestExecutionCallback then AfterEachCallbackAfterAllCallback (once, after the class)Extension callbacks wrap your own lifecycle methods: they run before @BeforeEach and bracket the body more tightly.
The stack is read top to bottom in time. The important rows are the second and third: an extension’s BeforeEachCallback runs before your @BeforeEach method, so an extension can prepare the world your set-up code then relies on. The after-side mirrors it in reverse.
Q10. What is the JUnit extension model, and in what order do the callbacks run? An extension is a class implementing one or more callback interfaces (BeforeAllCallback, BeforeEachCallback, BeforeTestExecutionCallback, and their After mirrors) plus optional ones like ParameterResolver. It replaces JUnit 4’s @RunWith and @Rule with one composable mechanism. The order matters: an extension’s beforeEach runs before your @BeforeEach method, and beforeTestExecution runs after it, immediately before the test body. Test code, from ExtensionFixtures.java (the Tracer extension, in the same file, logs each callback):
@ExtendWith(Tracer.class)
public static class CallbackOrder {
    @BeforeEach
    void beforeEachMethod() {
        Log.add("test class: @BeforeEach method");
    }

    @Test
    void theTest() {
        Log.add("test class: the test body");
    }
}
Captured output, from q10-extension-callback-order.txt:
declared: beforeAll
declared: beforeEach
test class: @BeforeEach method
declared: beforeTestExecution
test class: the test body
declared: afterTestExecution
declared: afterEach
declared: afterAll
Go deeper: Extension model: build custom extensions · @ExtendWith guide · Advanced extensions
Q11. What is the difference between @ExtendWith and @RegisterExtension? @ExtendWith(Foo.class) is declarative: you name the class and JUnit instantiates it with a no-argument constructor. @RegisterExtension marks a field: you build the object yourself, so you can pass constructor arguments, keep a reference, and call its methods from tests. The fixture uses both on one class. In this run the declared extension’s before callbacks came first and the field’s came second, and the after callbacks unwound in the opposite order. Test code, from ExtensionFixtures.java:
@ExtendWith(Tracer.class)
public static class Registration {
    @RegisterExtension
    static final Tracer PROGRAMMATIC = new Tracer("programmatic(static field)");

    @Test
    void theTest() {
        Log.add("test body");
    }
}
Captured output, from q11-extend-with-vs-register-extension.txt:
declared: beforeAll
programmatic(static field): beforeAll
declared: beforeEach
programmatic(static field): beforeEach
declared: beforeTestExecution
programmatic(static field): beforeTestExecution
test body
programmatic(static field): afterTestExecution
declared: afterTestExecution
programmatic(static field): afterEach
declared: afterEach
programmatic(static field): afterAll
declared: afterAll
Trap. Do not rely on ordering between several extensions of the same kind; if two extensions must cooperate, make the dependency explicit. Per the user guide, a @RegisterExtension field must not be private and needs to be static for class-level callbacks such as beforeAll to reach it (documented, not executed here).
Go deeper: @RegisterExtension in depth · @ExtendWith
Q12. How do you inject your own objects into tests (ParameterResolver)? Implement ParameterResolver with two methods: supportsParameter says whether you handle a parameter, resolveParameter produces the value. Register it and any test method, lifecycle method or constructor with that parameter type receives it. JUnit already does this for TestInfo, RepetitionInfo and TestReporter; the fixture asks for one custom and one built-in parameter in the same method. Test code, from ExtensionFixtures.java:
public static class TenantResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
        return p.getParameter().getType() == Tenant.class;
    }

    @Override
    public Object resolveParameter(ParameterContext p, ExtensionContext c) {
        return new Tenant("tenant-for-" + c.getRequiredTestMethod().getName());
    }
}

@ExtendWith(TenantResolver.class)
public static class Injection {
    @Test
    void resolvesCustomAndBuiltIn(Tenant tenant, TestInfo info) {
        Log.add("custom parameter : " + tenant);
        Log.add("built-in TestInfo: displayName=" + info.getDisplayName());
    }
}
Captured output, from q12-parameter-resolver.txt:
custom parameter : Tenant[name=tenant-for-resolvesCustomAndBuiltIn]
built-in TestInfo: displayName=resolvesCustomAndBuiltIn(Tenant, TestInfo)
Go deeper: Extension model · Building custom testing frameworks

Parameterized, dynamic, conditional and temp-dir tests

One test method, many inputs; tests generated at runtime; tests that sometimes should not run. The display-name behaviour in 6 is the detail that catches people after an upgrade.
Q13. What parameterized-test sources exist, and what does the report look like? @ParameterizedTest runs one method many times. The source annotation supplies the arguments: @ValueSource (one literal per call), @CsvSource (several columns), @MethodSource (a factory returning Arguments), @EnumSource, and @NullAndEmptySource (null and empty, optionally combined with @ValueSource). Every invocation is reported on its own line, so you can see which input failed. Test code, from ParamFixtures.java:
public static class Sources {
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3})
    void valueSource(int n) {
        assertTrue(n > 0);
    }

    @ParameterizedTest
    @CsvSource({"apple, 1", "banana, 2"})
    void csvSource(String fruit, int qty) {
        assertTrue(qty > 0);
    }

    @ParameterizedTest
    @MethodSource("pairs")
    void methodSource(String key, int value) {
        assertTrue(value > 0);
    }

    static Stream<Arguments> pairs() {
        return Stream.of(Arguments.of("x", 10), Arguments.of("y", 20));
    }

    @ParameterizedTest
    @EnumSource(Level.class)
    void enumSource(Level level) {
        assertTrue(level != null);
    }

    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {" "})
    void nullAndEmpty(String s) {
        assertTrue(s == null || s.isBlank());
    }
}
Captured output, from q13-parameterized-sources.txt:
enumSource(Level) / [1] LOW -> SUCCESSFUL
enumSource(Level) / [2] HIGH -> SUCCESSFUL
valueSource(int) / [1] 1 -> SUCCESSFUL
valueSource(int) / [2] 2 -> SUCCESSFUL
valueSource(int) / [3] 3 -> SUCCESSFUL
nullAndEmpty(String) / [1] null -> SUCCESSFUL
nullAndEmpty(String) / [2] "" -> SUCCESSFUL
nullAndEmpty(String) / [3] " " -> SUCCESSFUL
csvSource(String, int) / [1] "apple", "1" -> SUCCESSFUL
csvSource(String, int) / [2] "banana", "2" -> SUCCESSFUL
methodSource(String, int) / [1] "x", 10 -> SUCCESSFUL
methodSource(String, int) / [2] "y", 20 -> SUCCESSFUL
What changed in 6. Text arguments are now quoted in the display name, as in "apple", "1" above. Notice that the number is quoted too: @CsvSource hands over the raw text, and conversion to int happens afterwards, so the display name shows the text. A test that parses display names, or a report diff in CI, will notice the quotes after an upgrade. Go deeper: Parameterized tests: all sources explained · Complete @ParameterizedTest guide
Q14. How does @CsvSource treat empty and null values? An unquoted empty value becomes null; an empty quoted value ('') becomes the empty string; nullValues lets you name extra tokens that mean null; a quoted value keeps its comma. All four appear in one run below. From 6.0 the CSV is parsed by FastCSV, and the release notes warn that malformed input may now throw different exceptions, and that stray characters after a closing quote now fail (release-notes-sourced). Test code, from ParamFixtures.java:
public static class CsvConversion {
    @ParameterizedTest(name = "[{index}] a={0} b={1}")
    @CsvSource(value = {"x,", ",y", "'',z", "NIL,w", "'a,b',q"}, nullValues = "NIL")
    void csv(String a, String b) {
        Log.add("a=" + show(a) + "  b=" + show(b));
    }

    private static String show(String s) {
        return s == null ? "null" : "\"" + s + "\"";
    }
}
Captured output, from q14-csv-null-and-empty.txt:
display name: csv(String, String) / [1] a="x" b=null -> SUCCESSFUL
display name: csv(String, String) / [2] a=null b="y" -> SUCCESSFUL
display name: csv(String, String) / [3] a="" b="z" -> SUCCESSFUL
display name: csv(String, String) / [4] a=null b="w" -> SUCCESSFUL
display name: csv(String, String) / [5] a="a,b" b="q" -> SUCCESSFUL
--- values the test method received ---
a="x"  b=null
a=null  b="y"
a=""  b="z"
a=null  b="w"
a="a,b"  b="q"
Even with a custom name pattern the string arguments are quoted in the display name (a="x"), which is the 6.x behaviour from Q13. Go deeper: Parameterized tests guide · 6.0.0 release notes
Q15. What is a @TestFactory dynamic test, and how is it different from a parameterized test? A @TestFactory method returns a collection or stream of DynamicTest objects built at runtime; use it when the test cases are not known until the program runs (files in a directory, rows from a query). A parameterized test has its arguments declared up front. The practical difference interviewers like: lifecycle callbacks. The output shows @BeforeEach running once for the factory even though three dynamic tests ran. The user guide says the opposite holds for parameterized tests, where each invocation gets the full lifecycle (documented, not run here). Test code, from ParamFixtures.java:
public static class Dynamic {
    static int beforeEachRuns;

    @BeforeEach
    void beforeEach() {
        beforeEachRuns++;
        Log.add("@BeforeEach ran (" + beforeEachRuns + " time so far)");
    }

    @TestFactory
    List<DynamicTest> squares() {
        return List.of(1, 2, 3).stream()
                .map(n -> DynamicTest.dynamicTest("square of " + n,
                        () -> assertTrue(n * n >= n)))
                .toList();
    }
}
Captured output, from q15-dynamic-tests.txt:
@BeforeEach ran (1 time so far)
squares() / square of 1 -> SUCCESSFUL
squares() / square of 2 -> SUCCESSFUL
squares() / square of 3 -> SUCCESSFUL
Go deeper: Dynamic tests with @TestFactory · @TestFactory beyond parameterized tests · @TestTemplate
Q16. How do you disable, conditionally run, or tag tests? @Disabled("reason") skips unconditionally and the reason appears in the report. @EnabledIf... and @DisabledIf... annotations (OS, JRE, system property, environment variable, custom method) decide at runtime. @Tag labels tests so the build can select them: the second block below applies a Launcher TagFilter, which is what Surefire’s groups setting does for you. Test code, from ParamFixtures.java:
public static class Conditions {
    @Test
    @Disabled("waiting for the fix in ticket 1234")
    void disabled() {
    }

    @Test
    @EnabledIfSystemProperty(named = "lab.run.slow", matches = "true")
    void onlyWithProperty() {
    }

    @Test
    @Tag("fast")
    void fast() {
    }

    @Test
    @Tag("slow")
    void slow() {
    }
}
Captured output, from q16-disabled-conditional-tags.txt:
--- no filter ---
disabled() -> SKIPPED (waiting for the fix in ticket 1234)
fast() -> SUCCESSFUL
onlyWithProperty() -> SKIPPED (System property [lab.run.slow] does not exist)
slow() -> SUCCESSFUL
--- includeTags("fast") ---
fast() -> SUCCESSFUL
Trap. A skip with a vague reason is permanent in practice. The report line System property [lab.run.slow] does not exist is the kind of message you want: it says exactly what to set. JRE-range conditions now default their minimum to Java 17 (release-notes-sourced).
Go deeper: Tags and test suites · Conditional execution · @DisplayName, @Nested, @Tag
Q17. What does @TempDir do? It injects a fresh temporary directory (as a Path or File) into a field or parameter, and JUnit deletes it when the test finishes. Tests that touch the file system should use it instead of hard-coded paths, so they cannot collide or leave debris behind. The transcript captures the directory existing inside the test and being gone afterwards. Test code, from ParamFixtures.java:
public static class TempDirs {
    public static volatile Path seen;

    @TempDir
    Path dir;

    @Test
    void writesAFile() throws Exception {
        Files.writeString(dir.resolve("a.txt"), "hello");
        seen = dir;
        Log.add("inside the test : dir exists = " + Files.exists(dir)
                + ", files = " + Files.list(dir).count());
    }
}
Captured output, from q17-temp-dir.txt:
inside the test : dir exists = true, files = 1
after the test finished : dir exists = false
Go deeper: Test data management strategies · User guide: @TempDir

Mockito: mocks, spies, stubbing and verification

Mockito questions are mostly “what is the default behaviour?” questions. The answers are short once you have seen the default in a transcript.
mock(Counter.class)mock.next()Mockito interceptsstubbed value, else default 0real next(): never runsspy(new Counter())spy.next()Mockito interceptsstubbed value if there is onereal next() runsBoth record every call, so verify() works on both. The difference is only what happens when nothing is stubbed (Q18).
Both objects intercept every call and remember it, which is why verify works on either. They differ only on the right-hand side of the picture: a mock stops at Mockito, a spy continues into the real object unless stubbed. Keep that single difference in mind and Q19 (why doReturn exists) falls out of it.
Q18. What is the difference between a mock and a spy? A mock is a stand-in: every method returns a default (0, false, null, empty) or whatever you stubbed, and the real code never runs. A spy wraps a real object: methods call the real code unless you stub them. Both record calls, so both can be verified. The Counter class counts how many times its real next() body ran, which makes the difference visible. Test code, from D_MockitoCoreTest.java:
@Test
void q18_mockVersusSpy() {
    Counter c1 = mock(Counter.class);
    Counter c2 = spy(new Counter());
    p("mock: next() -> %d, next() -> %d, real code ran %d times", c1.next(), c1.next(), c1.realCalls());
    p("spy : next() -> %d, next() -> %d, real code ran %d times", c2.next(), c2.next(), c2.realCalls());
    assertEquals(0, c1.next());
    assertEquals(3, c2.next());
    assertEquals(0, c1.realCalls());
    assertEquals(3, c2.realCalls());
}
Captured output, from q18-mock-vs-spy.txt:
mock: next() -> 0, next() -> 0, real code ran 0 times
spy : next() -> 1, next() -> 2, real code ran 2 times
Interview follow-up. Reaching for a spy is often a sign that the class under test does too much. Prefer a real collaborator or a mock; use a spy when you must keep most real behaviour and replace one method (legacy code you cannot refactor yet).
Go deeper: JUnit 6 with Mockito: mocking, spying, best practices · JUnit 6 vs Mockito: roles · Mockito 5 deep dive
Q19. Why do you write doReturn(…).when(spy) instead of when(spy.method()) on a spy? Because when(spy.next()) calls the real method while you are still setting up the stub; only then does Mockito see the call and replace it. If the real method has side effects or throws, you get them during setup. doReturn(x).when(spy).next() registers the stub without calling anything. The same style is required for stubbing void methods (Q23). Test code, from D_MockitoCoreTest.java:
@Test
void q19_whenVersusDoReturnOnSpy() {
    Counter viaWhen = spy(new Counter());
    when(viaWhen.next()).thenReturn(100);
    p("when(spy.next()).thenReturn(100): real method ran while stubbing = %d time(s)", viaWhen.realCalls());
    Counter viaDo = spy(new Counter());
    doReturn(100).when(viaDo).next();
    p("doReturn(100).when(spy).next()   : real method ran while stubbing = %d time(s)", viaDo.realCalls());
    p("both return %d and %d after stubbing", viaWhen.next(), viaDo.next());
    assertEquals(1, viaWhen.realCalls());
    assertEquals(0, viaDo.realCalls());
}
Captured output, from q19-when-vs-doreturn-on-spy.txt:
when(spy.next()).thenReturn(100): real method ran while stubbing = 1 time(s)
doReturn(100).when(spy).next()   : real method ran while stubbing = 0 time(s)
both return 100 and 100 after stubbing
Go deeper: Mocking, spying, best practices · Mockito 5 deep dive
Q20. How does verify() work, and what do its failure messages look like? verify(mock).method(args) asserts the call happened exactly once by default. Modifiers change the count: times(n), never(), atLeastOnce(); InOrder checks sequence across calls. The useful part is the failure text. “Wanted but not invoked” means the call never happened; “Argument(s) are different” lists the calls that did happen, which is usually enough to spot a typo or an off-by-one in the argument. Test code, from D_MockitoCoreTest.java:
@Test
void q20_verify() {
    PaymentGateway gw = mock(PaymentGateway.class);
    when(gw.charge("a", 500)).thenReturn(true);
    OrderService svc = new OrderService(gw, Clock.systemUTC());
    svc.place("a", 500);
    svc.place("b", 700);
    svc.place("a", 900);
    verify(gw, times(2)).charge(eq("a"), anyLong());
    verify(gw, atLeastOnce()).audit("declined b");
    verify(gw, never()).lookup("x");
    InOrder order = inOrder(gw);
    order.verify(gw).charge("a", 500);
    order.verify(gw).audit("charged a 500");
    order.verify(gw).charge("b", 700);
    p("times / atLeastOnce / never / inOrder: all passed");
    Throwable e1 = assertThrows(WantedButNotInvoked.class, () -> verify(gw).lookup("zzz"));
    p("--- verify(gw).lookup(\"zzz\") ---");
    p("%s", firstLines(e1, 2));
    Throwable e2 = assertThrows(ArgumentsAreDifferent.class, () -> verify(gw).audit("declined c"));
    p("--- verify(gw).audit(\"declined c\") ---");
    p("%s", firstLines(e2, 6));
    Throwable e3 = assertThrows(NeverWantedButInvoked.class, () -> verify(gw, never()).audit("declined b"));
    p("--- verify(gw, never()).audit(\"declined b\") ---");
    p("%s", firstLines(e3, 2));
}
Captured output, from q20-verify-times-never-inorder.txt:
times / atLeastOnce / never / inOrder: all passed
--- verify(gw).lookup("zzz") ---
Wanted but not invoked:
paymentGateway.lookup("zzz");
--- verify(gw).audit("declined c") ---
Argument(s) are different! Wanted:
paymentGateway.audit("declined c");
-> at lab.jm.D_MockitoCoreTest.lambda$q20_verify$1(D_MockitoCoreTest.java:90)
Actual invocations have different arguments:
paymentGateway.charge("a", 500L);
-> at lab.jm.OrderService.place(OrderService.java:17)
--- verify(gw, never()).audit("declined b") ---
paymentGateway.audit("declined b");
Never wanted here:
Trap. Over-verifying couples the test to the implementation: verify the outcomes that matter to the caller (a charge was attempted, an audit line was written), not every internal call.
Go deeper: Mocking, spying, best practices · Mockito 5 deep dive
Q21. Why does mixing a matcher with a raw value throw, and what is ArgumentCaptor for? Mockito needs to know, for each call, whether it is matching by equality or by matcher. If any argument uses a matcher (anyLong(), eq(..)), all must; otherwise you get InvalidUseOfMatchersException (“2 matchers expected, 1 recorded”). The fix is charge(eq("a"), anyLong()). When you want to inspect what was passed rather than match it, ArgumentCaptor records the argument for assertions after the call. Test code, from D_MockitoCoreTest.java:
@Test
void q21_matchersAndCaptor() {
    PaymentGateway gw = mock(PaymentGateway.class);
    InvalidUseOfMatchersException mixed = assertThrows(InvalidUseOfMatchersException.class,
            () -> when(gw.charge("a", anyLong())).thenReturn(true));
    p("when(gw.charge(\"a\", anyLong())) -> %s", mixed.getClass().getSimpleName());
    p("%s", firstLines(mixed, 3));
    OrderService svc = new OrderService(gw, Clock.systemUTC());
    svc.place("zoe", 1234);
    ArgumentCaptor<String> account = ArgumentCaptor.forClass(String.class);
    ArgumentCaptor<Long> cents = ArgumentCaptor.forClass(Long.class);
    verify(gw).charge(account.capture(), cents.capture());
    p("ArgumentCaptor saw account=%s cents=%d", account.getValue(), cents.getValue());
    assertEquals("zoe", account.getValue());
    assertEquals(1234L, cents.getValue());
}
Captured output, from q21-matchers-and-captor.txt:
when(gw.charge("a", anyLong())) -> InvalidUseOfMatchersException
Invalid use of argument matchers!
2 matchers expected, 1 recorded:
-> at lab.jm.D_MockitoCoreTest.lambda$q21_matchersAndCaptor$0(D_MockitoCoreTest.java:103)
ArgumentCaptor saw account=zoe cents=1234
Go deeper: Mocking, spying, best practices · ArgumentCaptor and strict stubs
Q22. What does MockitoExtension do, and what are strict stubs? @ExtendWith(MockitoExtension.class) creates the @Mock fields before each test and checks them afterwards. Its default strictness is strict stubs: a stubbing the test never used fails the test with UnnecessaryStubbingException. Unused stubs are dead set-up that hides what the test is really about, or a sign the code stopped calling something. @MockitoSettings(strictness = LENIENT) switches the check off for a class; lenient() exempts a single stub. Test code, from MockitoFixtures.java:
@ExtendWith(MockitoExtension.class)
public static class StrictByDefault {
    @Mock
    PaymentGateway gateway;

    @Test
    void unusedStub() {
        when(gateway.lookup("42")).thenReturn("Ada");
    }
}

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public static class Lenient {
    @Mock
    PaymentGateway gateway;

    @Test
    void unusedStub() {
        when(gateway.lookup("42")).thenReturn("Ada");
    }
}
Captured output, from q22-strict-stubs.txt:
MockitoExtension (default strictness):
  unusedStub() -> FAILED
  org.mockito.exceptions.misusing.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.jm.fixtures.MockitoFixtures$StrictByDefault.unusedStub(MockitoFixtures.java:25)
    Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
@MockitoSettings(strictness = LENIENT):
  unusedStub() -> SUCCESSFUL
Go deeper: Strict stubs in the Mockito 5 deep dive · JUnit 6 with Mockito
Q23. How do you stub exceptions, void methods and consecutive calls? thenReturn(a, b) gives consecutive results and the last one repeats; chain thenThrow to end with an exception. when(..) cannot wrap a void call (it does not compile), so use the do... family: doThrow, doNothing, doAnswer. thenAnswer computes a result from the invocation, and the BDD aliases (given(..).willAnswer(..)) read better in given/when/then tests. Test code, from D_MockitoCoreTest.java:
@Test
void q23_stubbingStyles() {
    PaymentGateway gw = mock(PaymentGateway.class);
    when(gw.charge("a", 1)).thenReturn(true, false).thenThrow(new IllegalStateException("gateway down"));
    p("consecutive stubbing: %s, %s", gw.charge("a", 1), gw.charge("a", 1));
    IllegalStateException ex = assertThrows(IllegalStateException.class, () -> gw.charge("a", 1));
    p("third call throws   : %s", ex.getMessage());
    doThrow(new RuntimeException("audit store full")).when(gw).audit("x");
    RuntimeException v = assertThrows(RuntimeException.class, () -> gw.audit("x"));
    p("void method via doThrow: %s", v.getMessage());
    given(gw.lookup("7")).willAnswer(inv -> "user-" + inv.getArgument(0));
    p("thenAnswer / BDD willAnswer: lookup(\"7\") = %s", gw.lookup("7"));
    assertEquals("user-7", gw.lookup("7"));
    // Stubbing a void method with when(...) does not compile, which is why doThrow/doNothing exist.
}
Captured output, from q23-stubbing-styles.txt:
consecutive stubbing: true, false
third call throws   : gateway down
void method via doThrow: audit store full
thenAnswer / BDD willAnswer: lookup("7") = user-7
Go deeper: JUnit 6 with Mockito · Modern Java testing stack
Q28. What does an unstubbed mock return? Mockito’s default answer (RETURNS_DEFAULTS) returns the zero value: 0 for numbers, false, empty collections, Optional.empty(), and null for everything else, including String. That is why a forgotten stub does not fail loudly: the code under test quietly takes the “nothing found” path. Test code, from D_MockitoCoreTest.java:
@Test
void q28_unstubbedDefaults() {
    Probe m = mock(Probe.class);
    p("int            -> %s", m.number());
    p("boolean        -> %s", m.flag());
    p("String         -> %s", m.text());
    p("List<String>   -> %s", m.list());
    p("Optional<String> -> %s", m.opt());
    p("long           -> %s", m.big());
    assertEquals(0, m.number());
    assertEquals(List.of(), m.list());
    assertEquals(Optional.empty(), m.opt());
    assertTrue(m.text() == null);
}

interface Probe {
    int number();

    boolean flag();

    String text();

    List<String> list();

    Optional<String> opt();

    long big();
}
Captured output, from q28-unstubbed-defaults.txt:
int            -> 0
boolean        -> false
String         -> null
List<String>   -> []
Optional<String> -> Optional.empty
long           -> 0
Trap. In Q20 the account "b" was never stubbed, so charge returned false and the service answered DECLINED. A test that expected a different outcome would have failed for a reason that looks unrelated to the missing stub.
Go deeper: Mocking, spying, best practices · Common JUnit testing mistakes

Mockito 5: final classes, static methods, constructors and the agent

These are the questions that separate people who have only used mocks on interfaces from people who have mocked legacy code. Q27 is the one you will actually meet first: on a fresh JDK 21 or newer project the agent warning appears before any test of your own does.
Q24. What changed in Mockito 5, and can it mock final classes? Mockito 5 made the inline mock maker the default and raised the baseline to Java 11 (per the Mockito 5.0.0 release notes; both confirmed below). The inline maker instruments the class itself rather than generating a subclass, so final classes and methods can be mocked with no extra configuration (this module’s pom.xml has no inline-mock-maker dependency or config file, and Q24 passes). A visible side effect: the mock’s getClass() is the real class. Test code, from E_MockitoAdvancedTest.java:
@Test
void q24_finalClassesAndMockMaker() throws Exception {
    FinalPricer pricer = mock(FinalPricer.class);
    when(pricer.price(3)).thenReturn(999);
    p("FinalPricer is final: %s", java.lang.reflect.Modifier.isFinal(FinalPricer.class.getModifiers()));
    p("mock(FinalPricer.class).price(3) = %d (real code would give 30)", pricer.price(3));
    p("mock.getClass() == FinalPricer.class : %s", pricer.getClass() == FinalPricer.class);
    var maker = org.mockito.internal.configuration.plugins.Plugins.getMockMaker();
    p("default MockMaker : %s", maker.getClass().getName());
    int major;
    try (InputStream in = org.mockito.Mockito.class.getResourceAsStream("Mockito.class")) {
        byte[] head = in.readNBytes(8);
        major = ((head[6] & 0xff) << 8) | (head[7] & 0xff);
    }
    p("class-file major version of org.mockito.Mockito : %d (Java %d)", major, major - 44);
    assertEquals(999, pricer.price(3));
    assertTrue(pricer.getClass() == FinalPricer.class);
    assertTrue(maker.getClass().getSimpleName().contains("Inline"));
    assertEquals(55, major);
}
Captured output, from q24-final-classes-mock-maker.txt:
FinalPricer is final: true
mock(FinalPricer.class).price(3) = 999 (real code would give 30)
mock.getClass() == FinalPricer.class : true
default MockMaker : org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker
class-file major version of org.mockito.Mockito : 55 (Java 11)
The same release notes say the subclass mock maker is still available as a separate artifact, and the inline one does not work on GraalVM native images (release-notes-sourced). The test reads Plugins.getMockMaker() from Mockito’s internal package to print the class name; that is for demonstration, not something to use in production code. Go deeper: Static, final and constructor mocking · JUnit 6 + Mockito 5 stack
Q25. How does mockStatic work, and what is its catch? Mockito.mockStatic(Ids.class) returns a MockedStatic that replaces the static methods only inside the try-with-resources block. Close it (or forget to) and the real behaviour comes back. The catch is that it is thread-local: a thread you start inside the block still sees the real static method. The output shows all four moments. Test code, from E_MockitoAdvancedTest.java:
@Test
void q25_mockStatic() throws Exception {
    p("before            : %s", Ids.newOrderId());
    AtomicReference<String> otherThread = new AtomicReference<>();
    try (MockedStatic<Ids> ids = mockStatic(Ids.class)) {
        ids.when(Ids::newOrderId).thenReturn("MOCK-ID");
        p("inside the scope  : %s", Ids.newOrderId());
        Thread t = new Thread(() -> otherThread.set(Ids.newOrderId()));
        t.start();
        t.join();
        p("other thread sees : %s", otherThread.get());
        ids.verify(Ids::newOrderId, org.mockito.Mockito.times(1));
    }
    p("after the scope   : %s", Ids.newOrderId());
    assertEquals("REAL-ID", otherThread.get());
    assertEquals("REAL-ID", Ids.newOrderId());
}
Captured output, from q25-mock-static.txt:
before            : REAL-ID
inside the scope  : MOCK-ID
other thread sees : REAL-ID
after the scope   : REAL-ID
Interview follow-up. If you need mockStatic often, the design is asking for a seam: pass an IdGenerator in, or use a Clock as in Q29. Static mocking is a tool for code you cannot change.
Go deeper: Static mocking in the Mockito 5 deep dive · JUnit 6 with Mockito
Q26. What is mockConstruction for? When code does new Receipt(text) inside a method, you have no way to hand it a mock. mockConstruction makes every instance created inside the try block a mock, and your initializer lambda receives each new mock plus a context holding the constructor arguments. Like mockStatic, it is scoped: after the block the real constructor is back. Test code, from E_MockitoAdvancedTest.java:
@Test
void q26_mockConstruction() {
    p("before : %s", Receipt.print("tea"));
    try (MockedConstruction<Receipt> mc = mockConstruction(Receipt.class,
            (mock, ctx) -> when(mock.render()).thenReturn("FAKE " + ctx.arguments()))) {
        p("inside : %s", Receipt.print("tea"));
        p("constructed during the scope: %d", mc.constructed().size());
        assertEquals(1, mc.constructed().size());
    }
    p("after  : %s", Receipt.print("tea"));
    assertFalse(Receipt.print("tea").startsWith("FAKE"));
}
Captured output, from q26-mock-construction.txt:
before : RECEIPT[tea]
inside : FAKE [tea]
constructed during the scope: 1
after  : RECEIPT[tea]
Go deeper: Constructor mocking · JUnit 6 with Mockito
Q27. Why does Mockito print an “agent loaded dynamically” warning on a recent JDK, and how do you fix it? Mockito’s inline mock maker needs instrumentation access. If the JVM was not started with Mockito as an agent, Mockito attaches one to itself at runtime using byte-buddy-agent. Modern JDKs warn about that and have announced it will be disallowed by default in a future release. Running one test on JDK 25 without the flag produced exactly this (only the JVM-warning lines are kept; the file is the output of no-agent.sh): Captured output, from q27-no-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 (~/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.7/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 start the test JVM with Mockito as an agent. This module does it through Surefire’s argLine (from pom.xml), and a normal run prints none of the lines above:
<!-- Mockito's recommended setup on JDK 21+: load its agent at JVM start instead of self-attaching.
     scripts/no-agent.sh overrides this property to reproduce the warning (Q27). -->
<mockito.agentArg>-javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar</mockito.agentArg>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.6.0</version>
  <configuration>
    <argLine>${mockito.agentArg} -Duser.timezone=UTC -Dfile.encoding=UTF-8</argLine>
  </configuration>
</plugin>
Honest footnote: with the agent flag, the JVM prints a different one-line notice, Sharing is only supported for boot loader classes because bootstrap classpath has been appended, which is the class-data-sharing archive being skipped. It is harmless and appeared in every run while building this module. Go deeper: Mockito 5 deep dive · Mockito javadoc: agent setup

Flaky tests: reproduce first, then remove the cause

Flakiness questions test judgment more than API knowledge. The honest answer is that retrying is not a fix.
order a_, b_a_registersUserb_assumesUserExistsregistry has 1 entry: passesorder b_, a_b_assumesUserExistsa_registersUserregistry is empty: failsSame code, same assertions. Only the order changed, which is why this class of bug looks random in CI.
The two timelines contain the same two tests. In the first the order happens to suit the hidden dependency; in the second it does not. In a real suite the order can change when someone adds a test, changes the parallel setting or upgrades the framework, which is why such failures feel random.
Q29. What makes a test flaky, and how do you remove the two most common causes? A flaky test passes and fails on the same code. The usual suspects are shared mutable state (tests that only pass in a particular order), time (the wall clock, time zones, “now”), concurrency and timeouts, and external services. The first two can be reproduced on a laptop. Below, two tests share a static list; run alphabetically they pass, run in reverse the second one fails. The same assertions, the same code: only the order changed (a custom MethodOrderer in the fixture reverses it). Test code, from FlakyFixtures.java:
public static class SharedState {
    public static final List<String> REGISTRY = new ArrayList<>();

    @Test
    void a_registersUser() {
        REGISTRY.add("ada");
    }

    @Test
    void b_assumesUserExists() {
        assertEquals(1, REGISTRY.size(), "registry should contain the user registered by another test");
    }
}

public static class ReverseOrder implements MethodOrderer {
    @Override
    public void orderMethods(MethodOrdererContext context) {
        context.getMethodDescriptors().sort(
                (x, y) -> y.getMethod().getName().compareTo(x.getMethod().getName()));
    }
}
Captured output, from q29-flaky-order-and-time.txt:
--- order a_, b_ (alphabetical) ---
a_registersUser() -> SUCCESSFUL
b_assumesUserExists() -> SUCCESSFUL
--- order b_, a_ (reversed) ---
b_assumesUserExists() -> FAILED
a_registersUser() -> SUCCESSFUL
  AssertionFailedError: registry should contain the user registered by another test ==> expected: <1> but was: <0>
--- time: the same code with an injected Clock ---
acceptsOrders() at 10:00 UTC : true
acceptsOrders() at 23:30 UTC : false
For time, do not call LocalTime.now() in code you want to test; take a Clock. OrderService.acceptsOrders uses LocalTime.now(clock), and the test gives it a fixed clock, so the answer is the same at 10:00 and 23:30 whatever time the build actually runs. The last two lines of the transcript show it. Go deeper: How to fix flaky tests in JUnit 6 · Common JUnit testing mistakes · Parallel execution pitfalls
Q30. How do you reproduce an intermittent failure, and why is automatic retry risky? @RepeatedTest(n) runs the same test many times, which turns “fails sometimes in CI” into something you can trigger locally. failureThreshold stops the repetition after that many failures so you do not wait for the rest. The fixture fails on repetition 3 of 6. The first method (threshold 1) skips repetitions 4 to 6; the second (no threshold) runs all six and reports one failure. Test code, from FlakyFixtures.java:
public static class Intermittent {
    @RepeatedTest(value = 6, failureThreshold = 1)
    void failsOnThirdRepetition(RepetitionInfo info) {
        assertTrue(info.getCurrentRepetition() != 3, "intermittent failure on repetition 3");
    }

    @RepeatedTest(value = 6)
    void withoutThreshold(RepetitionInfo info) {
        assertTrue(info.getCurrentRepetition() != 3, "intermittent failure on repetition 3");
    }
}
Captured output, from q30-repeated-test-failure-threshold.txt:
failsOnThirdRepetition(RepetitionInfo) / repetition 1 of 6 -> SUCCESSFUL
failsOnThirdRepetition(RepetitionInfo) / repetition 2 of 6 -> SUCCESSFUL
failsOnThirdRepetition(RepetitionInfo) / repetition 3 of 6 -> FAILED
failsOnThirdRepetition(RepetitionInfo) / repetition 4 of 6 -> SKIPPED (Failure threshold [1] exceeded)
failsOnThirdRepetition(RepetitionInfo) / repetition 5 of 6 -> SKIPPED (Failure threshold [1] exceeded)
failsOnThirdRepetition(RepetitionInfo) / repetition 6 of 6 -> SKIPPED (Failure threshold [1] exceeded)
withoutThreshold(RepetitionInfo) / repetition 1 of 6 -> SUCCESSFUL
withoutThreshold(RepetitionInfo) / repetition 2 of 6 -> SUCCESSFUL
withoutThreshold(RepetitionInfo) / repetition 3 of 6 -> FAILED
withoutThreshold(RepetitionInfo) / repetition 4 of 6 -> SUCCESSFUL
withoutThreshold(RepetitionInfo) / repetition 5 of 6 -> SUCCESSFUL
withoutThreshold(RepetitionInfo) / repetition 6 of 6 -> SUCCESSFUL
failed=2 skipped=3
Trap. Build-level retry (for example Surefire’s rerunFailingTestsCount) can turn a red build green and report the test as “flaky”. That keeps the pipeline moving but hides a real defect; treat it as a short-term bandage with a ticket attached (documented, not executed here).
Go deeper: @RepeatedTest and real flaky-test detection · Fixing flaky tests

Six answers where the code disagreed with the folklore

  • Default test order is neither declared nor alphabetical. The fixture declares zebra, apple, mango, banana and the default run produced banana, apple, mango, zebra (Q6).
  • @BeforeEach runs once for a @TestFactory, not once per dynamic test (Q15).
  • A mocked final class reports getClass() == RealClass.class; there is no generated subclass to see (Q24).
  • mockStatic is invisible to other threads: a thread started inside the block called the real method (Q25).
  • Numbers are quoted in parameterized display names ("apple", "1"), because the CSV text is shown before conversion (Q13).
  • The recommended Mockito agent flag is not silent: it removes the dynamic-agent warnings and adds a one-line class-data-sharing notice (Q27).

Quick reference: the 30 questions

AreaQuestions
Platform and JUnit 6 changesQ1, Q2
Lifecycle and orderingQ3 to Q6
Assertions, timeouts, assumptionsQ7 to Q9
ExtensionsQ10 to Q12
Parameterized, dynamic, conditional, temp dirQ13 to Q17
Mockito coreQ18 to Q23, Q28
Mockito 5 advancedQ24 to Q27
Flaky testsQ29, Q30
Should you memorise this list? No. Each answer is a mechanism, and an interviewer’s follow-up will move one step past what you memorised. The better preparation is to run the module, change one line, and predict the output first. Two further cautions: static and constructor mocking (Q25, Q26) are tools for code you cannot change, not a style to build new code around; and a suite that needs many spies or strict-stub exemptions is usually telling you a class has too many responsibilities. Finally, every answer has an executed transcript, but individual sentences marked “release-notes-sourced” or “documented, not executed” (most of the migration table in Q2) were not run here, so check the notes for the exact version you are upgrading to.

Further reading

No Comments yet!

Leave a Reply

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