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.
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());
}
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 guideQ2. 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)");
}
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.0
What to say in an interview
Source
Java 17 minimum; Kotlin 2.2 minimum
Builds on Java 8 or 11 cannot load the jars
Executed (Java), release notes (Kotlin)
One version for Platform, Jupiter, Vintage
No more 1.x/5.x pairing table
Executed (Q1)
MethodOrderer.Alphanumeric removed
Use MethodOrderer.MethodName
Executed (Q6)
junit-platform-runner removed; junit-platform-jfr folded into the launcher; junit-platform-suite-commons merged into junit-platform-suite
The JUnit 4 @RunWith(JUnitPlatform.class) runner is gone
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.
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)");
}
}
@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 @AfterAllQ4. 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);
}
}
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 explainedQ5. 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:
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, @TagQ6. 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));
}
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.
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:
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 testQ8. 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));
}
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 guideQ9. 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");
}
}
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.
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”.
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");
}
}
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 extensionsQ11. 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");
}
}
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 · @ExtendWithQ12. 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());
}
}
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:
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 guideQ14. 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 + "\"";
}
}
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 notesQ15. 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();
}
}
@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 · @TestTemplateQ16. 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() {
}
}
--- 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, @TagQ17. 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());
}
}
Mockito questions are mostly “what is the default behaviour?” questions. The answers are short once you have seen the default in a transcript.
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:
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 diveQ19. 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());
}
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 diveQ20. 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:
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 diveQ21. 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:
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 stubsQ22. 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");
}
}
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 MockitoQ23. 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.
}
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 stackQ28. 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:
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.
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);
}
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 stackQ25. 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());
}
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 MockitoQ26. 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:
before : RECEIPT[tea]
inside : FAKE [tea]
constructed during the scope: 1
after : RECEIPT[tea]
Go deeper:Constructor mocking · JUnit 6 with MockitoQ27. 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>
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.
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()));
}
}
--- 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 pitfallsQ30. 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:
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).
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
Area
Questions
Platform and JUnit 6 changes
Q1, Q2
Lifecycle and ordering
Q3 to Q6
Assertions, timeouts, assumptions
Q7 to Q9
Extensions
Q10 to Q12
Parameterized, dynamic, conditional, temp dir
Q13 to Q17
Mockito core
Q18 to Q23, Q28
Mockito 5 advanced
Q24 to Q27
Flaky tests
Q29, 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.
No Comments yet!