JUnit Interview Questions and Answers

Last updated:

Check out 45 of the most common JUnit interview questions, then take an AI-powered practice interview

JavaUnit TestingTest AnnotationsAssertionsMaven
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

How do JUnit Platform, JUnit Jupiter and JUnit Vintage fit together, and which artifacts do you actually add to a build?

BasicArchitecture

Answer

JUnit 5 is not a single library, it is three sub-projects released together under one version. The JUnit Platform is the foundation: it defines the TestEngine SPI and the Launcher API that IntelliJ, Eclipse, Maven Surefire and Gradle talk to. Jupiter is the new programming model plus the engine that runs it, split into junit-jupiter-api (the annotations and Assertions class you compile against), junit-jupiter-params (parameterized test support) and junit-jupiter-engine (the runtime that discovers and executes them).

Vintage is a separate TestEngine that runs old JUnit 3 and 4 tests on the same Platform, which is what makes an incremental migration possible instead of a big-bang rewrite. In Maven you import org.junit:junit-bom into dependencyManagement so every JUnit artifact stays version-aligned, then declare the junit-jupiter aggregator at test scope. Add junit-vintage-engine only while legacy tests remain, and delete it the day the last one is migrated so nobody accidentally writes new JUnit 4 code.

The classic build failure here is having the API on the compile path but no engine at runtime: the code compiles, then Surefire prints that no tests were executed and the build goes green with zero coverage. Gradle users additionally need testRuntimeOnly on junit-platform-launcher. Interviewers ask this to check that you see JUnit as a pluggable platform, which is also how Spock, Cucumber, jqwik and Kotest run inside the same test task.

<!-- pom.xml -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>${junit.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>

  <!-- keep ONLY while JUnit 4 tests still exist -->
  <dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Key Points

  • Platform = TestEngine SPI + Launcher that IDEs and build tools call
  • Jupiter = api + params + engine, the model you write tests in
  • Vintage = engine that runs JUnit 3/4 tests during migration
  • Import junit-bom so all JUnit artifact versions stay aligned
  • API without engine at runtime means zero tests run and the build still passes
💡 Pro Tip: If a build suddenly reports zero tests after an upgrade, check the runtime classpath for an engine before you touch a single test file.
Q2

Which JUnit 4 annotations changed in Jupiter, and which ones have no direct replacement at all?

BasicAnnotations

Answer

The renames are straightforward: @Before becomes @BeforeEach, @After becomes @AfterEach, @BeforeClass becomes @BeforeAll, @AfterClass becomes @AfterAll, @Ignore becomes @Disabled, @Category becomes @Tag, and @RunWith becomes @ExtendWith. Rules are the bigger change: @Rule and @ClassRule have no Jupiter equivalent, their behaviour moves into extensions registered with @ExtendWith or @RegisterExtension, and the junit-jupiter-migrationsupport module can run a subset of JUnit 4 rules if you need a bridge. Two attributes disappear entirely. @Test(expected = SomeException.class) becomes Assertions.assertThrows, which is strictly better because it returns the exception so you can assert on its message, cause and fields. @Test(timeout = 500) becomes the @Timeout annotation or Assertions.assertTimeout.

The assertion signature also flipped: JUnit 4 put the failure message first, Jupiter puts it last, and prefers a Supplier<String> overload. The trap interviewers love is the mixed-import class. If a file still imports org.junit.Test while its setup method uses org.junit.jupiter.api.BeforeEach, the class compiles cleanly, gets picked up by the Vintage engine, and the @BeforeEach method never runs, so the test either fails with a null field or, worse, passes for the wrong reason. Whenever you migrate a file, migrate every import in it and add an ArchUnit or Checkstyle rule banning org.junit.Test once the cutover is complete.

// JUnit 4
import org.junit.Before;
import org.junit.Test;

public class OrderTest {
  @Before public void setUp() { }

  @Test(expected = IllegalStateException.class, timeout = 500)
  public void rejectsEmptyCart() { new Order().checkout(); }
}

// JUnit 5 (Jupiter)
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import static org.junit.jupiter.api.Assertions.assertThrows;

class OrderTest {
  @BeforeEach void setUp() { }

  @Test
  @Timeout(value = 500, unit = java.util.concurrent.TimeUnit.MILLISECONDS)
  void rejectsEmptyCart() {
    IllegalStateException ex = assertThrows(
        IllegalStateException.class, () -> new Order().checkout());
    org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("empty"));
  }
}

Key Points

  • @Before/@After become @BeforeEach/@AfterEach, @BeforeClass/@AfterClass become @BeforeAll/@AfterAll
  • @RunWith becomes @ExtendWith, Rules become extensions
  • expected= becomes assertThrows, timeout= becomes @Timeout
  • Assertion message moved from first parameter to last
  • Mixed org.junit and org.junit.jupiter imports in one class silently break setup
Q3

Walk through the exact callback order JUnit 5 uses for a test class with @BeforeAll, @BeforeEach and a @Nested class.

BasicLifecycle

Answer

For a plain class the order is: @BeforeAll once, then for every test method a fresh instance of the test class is constructed, extension callbacks fire, @BeforeEach runs, the @Test body runs, @AfterEach runs, and after the last method @AfterAll runs once. The instance-per-method default is the single most important part of that sentence. Because JUnit constructs a new object for each test, instance fields cannot leak state between test methods, which is what makes tests independent by default.

Static fields absolutely do leak, and that is where order-dependent failures come from. With @Nested classes the outer callbacks wrap the inner ones: outer @BeforeAll, then for each inner test the outer instance is created, the inner instance is created, outer @BeforeEach runs, inner @BeforeEach runs, the test runs, inner @AfterEach, outer @AfterEach. Inherited lifecycle methods from a superclass follow the same rule, superclass @BeforeEach runs before subclass @BeforeEach, and superclass @AfterEach runs after.

If you declare two @BeforeEach methods in the same class, JUnit orders them deterministically but the algorithm is intentionally not the declaration order, so never rely on it. Merge them into one method, or move one into an extension where ordering is explicit. Interviewers usually follow up by asking where you would open a Testcontainers Postgres instance: @BeforeAll, because starting a container per test method turns a two-minute suite into twenty.

class LifecycleDemoTest {

  @BeforeAll static void beforeAll() { System.out.println("1 beforeAll"); }
  @BeforeEach void beforeEach() { System.out.println("2 beforeEach"); }
  @AfterEach void afterEach() { System.out.println("4 afterEach"); }
  @AfterAll static void afterAll() { System.out.println("5 afterAll"); }

  @Test void first() { System.out.println("3 first"); }
  @Test void second() { System.out.println("3 second"); }

  @Nested
  class WhenCartIsEmpty {
    @BeforeEach void innerSetup() { System.out.println("2b inner beforeEach"); }
    @Test void rejectsCheckout() { System.out.println("3 nested test"); }
  }
}
// 1, then per test: 2, (2b), 3, 4 ... finally 5

Key Points

  • New test class instance per test method is the default (Lifecycle.PER_METHOD)
  • Instance fields are isolated, static fields are not
  • Outer @BeforeEach runs before nested @BeforeEach, @AfterEach unwinds in reverse
  • Order of multiple @BeforeEach methods in one class is deterministic but undefined
Q4

Why must @BeforeAll be static by default, and what changes with @TestInstance(Lifecycle.PER_CLASS)?

BasicLifecycle

Answer

Because JUnit creates a new test class instance for every test method, a class-level hook cannot sensibly belong to any one of those instances, so @BeforeAll and @AfterAll must be static and can only touch static state. Annotating the class with @TestInstance(TestInstance.Lifecycle.PER_CLASS) tells JUnit to construct a single instance for the whole class. Under that mode @BeforeAll and @AfterAll may be non-static instance methods, they can read and write instance fields, and @MethodSource factory methods no longer need to be static either.

The trade-off is real: state now survives between test methods in that class, so a test that mutates a field can change the outcome of a later one, and you have reintroduced exactly the coupling instance-per-method was designed to prevent. Use PER_CLASS deliberately, usually when the expensive setup naturally produces an object you want as a field (a started WireMock server, a parsed fixture file, a Testcontainers client) and pair it with an @AfterEach that resets mutable state. You can flip the default for the whole project with junit.jupiter.testinstance.lifecycle.default=per_class in junit-platform.properties, which Kotlin teams often do because companion-object statics are clumsy. Two gotchas: PER_CLASS plus parallel method execution means several threads share one instance, so every field must be thread-safe, and PER_CLASS does not change @Nested behaviour unless the nested class carries its own annotation.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class InvoiceParserTest {

  private List<String> rawRows;   // instance field survives the class

  @BeforeAll
  void loadFixture() throws Exception {          // no longer static
    rawRows = Files.readAllLines(Path.of("src/test/resources/invoices.csv"));
  }

  // non-static @MethodSource is legal under PER_CLASS
  Stream<String> rows() { return rawRows.stream().skip(1); }

  @ParameterizedTest
  @MethodSource("rows")
  void everyRowParses(String row) {
    assertNotNull(InvoiceParser.parse(row));
  }
}
💡 Pro Tip: If you reach for PER_CLASS only to avoid writing static, do not. Reach for it when the shared setup genuinely costs seconds.
Q5

What is the argument order of Assertions.assertEquals, and which assertion overloads catch bugs the others miss?

BasicAssertions

Answer

Jupiter uses assertEquals(expected, actual) with the optional message as the last parameter, the reverse of JUnit 4 where the message came first. Getting expected and actual the wrong way round does not fail the test, it produces a failure report that lies to the next engineer who reads it, so treat the order as non-negotiable in review. Prefer the Supplier<String> overload for anything expensive, because a plain String message is built on every passing run too.

Beyond assertEquals the useful members of the family are: assertArrayEquals for arrays (assertEquals on arrays compares references), assertIterableEquals for element-by-element comparison across different Iterable implementations, assertLinesMatch which supports regular expressions and fast-forward markers for log or file output, assertSame and assertNotSame for identity, assertInstanceOf which returns the narrowed reference so you can chain assertions on it, and assertDoesNotThrow. The floating point overloads take a delta and you should always use them, assertEquals(0.1 + 0.2, 0.3) fails. The boxing trap comes up constantly in interviews: assertEquals(1, someLongValue) resolves to the Object overload, boxes the literal to Integer and the value to Long, and fails with a message that reads expected 1 but was 1.

Cast explicitly or use 1L. Finally, avoid assertTrue(a.equals(b)) because the failure message is just expected true but was false, with no diff to work from.

// expected first, actual second, message last
assertEquals(2500L, invoice.getAmountPaise(), () -> "unexpected total for " + invoice.getId());

assertEquals(0.3, 0.1 + 0.2, 1e-9);          // delta overload, required for doubles
assertArrayEquals(new int[]{1, 2, 3}, result);
assertIterableEquals(List.of("a", "b"), new LinkedList<>(List.of("a", "b")));

// returns the narrowed type, so you can keep asserting
CardPayment payment = assertInstanceOf(CardPayment.class, gateway.charge(req));
assertEquals("VISA", payment.network());

// boxing trap: this FAILS, Integer 1 does not equal Long 1
// assertEquals(1, repository.count());
assertEquals(1L, repository.count());

Key Points

  • Signature is assertEquals(expected, actual, message) with message last
  • Use the Supplier<String> overload so messages are not built on passing runs
  • assertEquals on arrays compares references, use assertArrayEquals
  • assertEquals(int, long) fails because of autoboxing to different wrapper types
  • assertInstanceOf returns the narrowed reference for chained assertions
Q6

What does assertAll do, and when is it the wrong choice?

BasicAssertions

Answer

assertAll takes a set of Executable lambdas (or a Stream of them), runs every one even if earlier ones fail, and aggregates the failures into a single MultipleFailuresError that lists all of them. Without it, the first failing assertEquals aborts the method and you learn about exactly one broken field per test run, which turns verifying a ten-field response DTO into a ten-round loop of fix, rerun, discover the next problem. assertAll also accepts a heading as the first argument, which prefixes the aggregated report, and the groups can be nested so you can structure a large object comparison. It is the wrong choice in two situations.

First, when a later assertion would throw a NullPointerException rather than fail cleanly: assertAll does not stop, so you get a confusing mix of an assertion failure and an NPE. Do a guard assertNotNull or assertInstanceOf outside the group first, then assertAll on the fields. Second, when the assertions have side effects or depend on each other, because they will all execute regardless of state.

In practice most teams that adopt AssertJ use SoftAssertions or a single assertThat(actual).usingRecursiveComparison().isEqualTo(expected) instead, which produces a field-by-field diff without writing the group by hand. Knowing both and being able to say why you chose one is what interviewers are listening for.

@Test
void mapsCandidateProfile() {
  Candidate c = mapper.toDomain(row);

  assertNotNull(c, "mapper returned null");   // guard first

  assertAll("candidate",
      () -> assertEquals("Saksham", c.firstName()),
      () -> assertEquals("Sandhu", c.lastName()),
      () -> assertEquals("Noida", c.city()),
      () -> assertAll("experience",
          () -> assertEquals(72, c.experienceMonths()),
          () -> assertTrue(c.skills().contains("Java"))));
}
// One run reports every mismatched field, not just the first.
Q7

How do you assert that code throws, and what is the difference between assertThrows and assertThrowsExactly?

BasicAssertions

Answer

assertThrows(ExpectedType.class, executable) runs the lambda, fails the test if nothing is thrown or if something of the wrong type is thrown, and returns the caught exception so you can keep asserting on it. That return value is the whole point and the reason it replaced JUnit 4's @Test(expected = ...): you can check the message, the cause chain, an error code field, or a suppressed exception. assertThrows accepts subclasses of the declared type, so asserting RuntimeException.class passes for an IllegalArgumentException. assertThrowsExactly requires the exact runtime class and fails on a subclass, which matters when your code has a hierarchy such as PaymentException with GatewayTimeoutException and CardDeclinedException under it, and you want to prove which one came out. Two habits separate good tests here.

Put only the call that should throw inside the lambda, never the setup, otherwise an unrelated exception of the same type thrown during setup makes the test pass for the wrong reason. And assert on something stable in the message with contains rather than equals, because messages that embed ids, timestamps or amounts will break the test on every unrelated change. Use assertDoesNotThrow for the inverse case, though it is mostly useful for readability since an uncaught exception fails the test anyway.

@Test
void declinesExpiredCard() {
  PaymentRequest req = validRequest();          // setup OUTSIDE the lambda
  req.setExpiry(YearMonth.of(2020, 1));

  CardDeclinedException ex = assertThrowsExactly(
      CardDeclinedException.class,
      () -> gateway.charge(req));               // only the call under test

  assertEquals("CARD_EXPIRED", ex.getCode());
  assertTrue(ex.getMessage().contains("expired"));
  assertInstanceOf(IllegalStateException.class, ex.getCause());
}

@Test
void acceptsValidCard() {
  assertDoesNotThrow(() -> gateway.charge(validRequest()));
}
💡 Pro Tip: If assertThrows wraps three lines of setup plus the call, the test can go green while the method under test is never reached.
Q8

How are Assumptions different from Assertions, and when should you actually use assumeTrue?

BasicAssumptions

Answer

An assertion that fails marks the test as failed. An assumption that fails throws TestAbortedException and marks the test as aborted, which build tools report as skipped, not red. Assumptions exist for environment preconditions that are outside the code under test: a Docker daemon is not running on this machine, an integration API key is absent, the test needs a Linux-only filesystem feature, the developer is on a laptop without VPN access to a staging database.

Assumptions.assumeTrue and assumeFalse abort the rest of the method, while assumingThat(condition, executable) runs only the enclosed block when the condition holds and lets the remainder of the test continue, which is handy when one section of a test is environment specific. The production danger is silence. A CI job where the credentials secret was renamed will abort an entire integration suite and still show a green pipeline, and nobody notices for weeks.

Guard against it by failing the build when the skipped count crosses a threshold, or by asserting rather than assuming on CI (assumeTrue(isLocalDeveloperMachine()) inverted). Where the condition is static, prefer the declarative annotations such as @EnabledIfEnvironmentVariable, because those state intent at the signature, appear in reports, and can be filtered. Interviewers use this question to check whether you understand that skipped is a third outcome, not a variant of passed.

import static org.junit.jupiter.api.Assumptions.*;

@Test
void pushesToS3() {
  assumeTrue(System.getenv("AWS_ACCESS_KEY_ID") != null,
      "no AWS credentials on this machine, skipping");

  uploader.put("resumes/1.pdf", bytes);
  assertTrue(uploader.exists("resumes/1.pdf"));
}

@Test
void computesPayroll() {
  Payroll p = service.run("2026-07");
  assertEquals(120, p.employeeCount());

  assumingThat("prod".equals(System.getProperty("env")),
      () -> assertNotNull(p.getFilingReference()));   // only checked on prod-like runs
}
Q9

Beyond @Disabled, what conditional execution annotations does Jupiter give you?

BasicConditional Execution

Answer

Jupiter ships a family of built-in ExecutionCondition extensions. @Disabled("reason") switches a class or method off unconditionally. @EnabledOnOs(OS.LINUX) and @DisabledOnOs handle platform-specific behaviour such as file permissions or path separators. @EnabledOnJre and @EnabledForJreRange(min = JRE.JAVA_17) gate tests on the runtime version, useful while a codebase straddles two JDKs. @EnabledIfSystemProperty(named = "env", matches = "ci") and @EnabledIfEnvironmentVariable(named = "CI", matches = "true") match a regular expression against a property or environment variable, and both have Disabled counterparts. @EnabledIf and @DisabledIf point at a boolean method for logic that cannot be expressed as a regex. Everything here is implemented through the same ExecutionCondition interface you can implement yourself, so a custom @EnabledOnStaging is about fifteen lines. One config key is worth remembering: junit.jupiter.conditions.deactivate accepts a pattern (often just an asterisk) that switches conditions off entirely, which is how you run a nightly job that executes everything the PR pipeline skips.

The judgment question interviewers attach to this is about @Disabled on a flaky test. Leaving a naked @Disabled with no reason is how suites rot, the honest answer is a tag-based quarantine plus a linked ticket plus a date, so the disabled set is visible and shrinking rather than permanent.

@Test
@DisabledOnOs(value = OS.WINDOWS, disabledReason = "uses POSIX file permissions")
void setsResumeFilePermissions() { }

@Test
@EnabledForJreRange(min = JRE.JAVA_21)
void usesVirtualThreadExecutor() { }

@Test
@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION", matches = "true")
void hitsRealGateway() { }

@Test
@EnabledIf("dockerAvailable")
void startsPostgresContainer() { }

boolean dockerAvailable() {
  return DockerClientFactory.instance().isDockerAvailable();
}

Key Points

  • @EnabledOnOs, @EnabledOnJre, @EnabledForJreRange for platform gating
  • @EnabledIfSystemProperty and @EnabledIfEnvironmentVariable match a regex
  • @EnabledIf and @DisabledIf call a boolean method for custom logic
  • junit.jupiter.conditions.deactivate forces skipped tests to run in a nightly job
Q10

How do @DisplayName and display name generators change what appears in reports?

BasicReporting

Answer

@DisplayName replaces the method or class name in IDE trees, Surefire and Gradle HTML reports, and CI test detail views with arbitrary text including spaces and punctuation. That matters more than it sounds: a failing build that says "refunds are rejected after 180 days" tells an on-call engineer what broke, while shouldReturnFalse4 tells them nothing. @DisplayNameGeneration applies a strategy to a whole class so you do not annotate every method. DisplayNameGenerator.ReplaceUnderscores converts refunds_are_rejected_after_180_days into readable text, DisplayNameGenerator.Simple strips the trailing parentheses, and IndicativeSentences composes the class display name with the method display name into one sentence, which reads beautifully with @Nested classes.

Set it for the whole project with junit.jupiter.displayname.generator.default in junit-platform.properties rather than repeating the annotation. Parameterized tests have their own naming mechanism through the name attribute, with placeholders such as {index}, {0} for the first argument, {arguments} and {argumentsWithNames}, so each invocation gets a distinguishable label instead of appearing as five identical rows. One practical caution: some CI systems and test-analytics tools key flaky-test history on the display name, so churning display names resets that history, and characters like commas can confuse report parsers. Pick a convention early and leave it alone.

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@DisplayName("Refund policy")
class RefundPolicyTest {

  @Test
  void refunds_are_rejected_after_180_days() { }

  @ParameterizedTest(name = "[{index}] {0} days since purchase -> refundable={1}")
  @CsvSource({"1, true", "179, true", "180, false", "400, false"})
  void refund_window(int days, boolean expected) {
    assertEquals(expected, policy.isRefundable(days));
  }
}

// junit-platform.properties
// junit.jupiter.displayname.generator.default=\
//   org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
Q11

How do you make Maven actually run JUnit 5 tests, and what is the difference between Surefire and Failsafe?

BasicBuild Tooling

Answer

Maven Surefire has had native JUnit Platform support since 2.22, so you no longer need the old junit-platform-surefire-provider artifact, just pin a recent Surefire 3.x. Surefire binds to the test phase and its default includes are **/Test*.java, **/*Test.java, **/*Tests.java and **/*TestCase.java. A class named UserServiceSpec or CheckoutTesting simply never runs, and this silent naming mismatch is one of the most common real-world reasons a suite is smaller than people think.

Failsafe is the same engine bound to different phases for integration tests: pre-integration-test, integration-test, post-integration-test and verify, with default includes **/IT*.java, **/*IT.java and **/*ITCase.java. The critical behavioural difference is that Failsafe does not fail the build during integration-test, it records the result and fails at verify, so your post-integration-test teardown (stopping containers, releasing ports) always runs. That is why anything using Testcontainers, an embedded Kafka or a real HTTP server belongs in a *IT class under Failsafe, not in Surefire where a crash leaves orphaned containers on the runner. Useful flags: -Dtest=CheckoutServiceTest#rejectsExpiredCard runs one method, -Dtest can take patterns and comma-separated lists, -DfailIfNoSpecifiedTests=false stops multi-module builds from failing in modules where the filter matches nothing, and -DskipITs skips Failsafe while still running unit tests.

<build>
  <plugins>
    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.2</version>
      <configuration>
        <includes><include>**/*Test.java</include></includes>
      </configuration>
    </plugin>
    <plugin>
      <artifactId>maven-failsafe-plugin</artifactId>
      <version>3.5.2</version>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

<!-- run one method -->
<!-- mvn test -Dtest=CheckoutServiceTest#rejectsExpiredCard -->

Key Points

  • Surefire 2.22+ speaks JUnit Platform natively, no provider artifact needed
  • Surefire runs *Test in the test phase, Failsafe runs *IT around integration-test
  • Failsafe fails at verify so teardown always executes
  • Class naming that misses the include pattern means the test silently never runs
Q12

What does a Gradle build need for JUnit 5, and what breaks most often there?

BasicBuild Tooling

Answer

Three things: call useJUnitPlatform() on the test task, put junit-jupiter on testImplementation, and put junit-platform-launcher on testRuntimeOnly. Forgetting useJUnitPlatform is the classic failure, because Gradle then uses its JUnit 4 runner, discovers nothing, marks the task successful and caches it as up to date, so subsequent runs do not even try. If a test task reports zero tests and then says UP-TO-DATE on the next run, that is the first thing to check, and ./gradlew test --rerun forces re-execution.

Inside the useJUnitPlatform block you can pass includeTags and excludeTags, which is how you split a fast PR pipeline from a nightly one without touching Maven-style file naming. maxParallelForks controls process-level parallelism (separate JVMs, so no shared-state risk but higher memory), while JUnit's own parallel execution is thread-level inside one JVM, and the two compose. testLogging with events("passed", "skipped", "failed") plus exceptionFormat FULL is worth adding on day one because Gradle's default output hides stack traces behind an HTML report nobody opens on CI. Configuration cache and the build cache both interact with tests: a test task whose inputs did not change is skipped entirely, which is correct but surprising when someone changes only an external fixture file that Gradle does not know about.

// build.gradle.kts
dependencies {
    testImplementation(platform("org.junit:junit-bom:${junitVersion}"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform {
        excludeTags("slow", "integration")
    }
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
    testLogging {
        events("passed", "skipped", "failed")
        exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
    }
}
💡 Pro Tip: Zero tests plus UP-TO-DATE on the second run almost always means useJUnitPlatform() is missing.
Q13

What are the rules for a valid Jupiter test method, and what are the usual causes of 'no tests found'?

BasicFundamentals

Answer

A Jupiter test method must not be private, must not be static, and must return void (a method returning a value is reported as a configuration error in recent versions rather than silently ignored). Unlike JUnit 4, it does not need to be public, so package-private methods and classes are the idiomatic style and keep the API surface clean. The test class itself must not be abstract, must have exactly one suitable constructor, and if it is an inner class it must be annotated @Nested.

Lifecycle methods follow the same visibility rules. When a suite reports no tests, work through this list. Is a JUnit engine on the runtime classpath at all, or only the API?

Does the class name match the Surefire include pattern, or did someone call it CheckoutSpec? Did Gradle get useJUnitPlatform()? Is the file under src/test/java rather than src/main/java?

Does the class import org.junit.Test from JUnit 4 while the Vintage engine is absent, in which case the class is invisible to both engines? Is the method annotated with org.junit.jupiter.api.Test or did the IDE auto-import org.junit.Test? Is a tag filter in the CI profile excluding everything? Interviewers ask this because it separates people who have configured a build from people who have only written test bodies inside an existing project.

class OrderTest {

  @Test
  void packagePrivateIsFine() { }        // public not required in Jupiter

  // @Test private void ignored() { }    // private is not discovered
  // @Test static void ignored() { }     // static is not discovered
  // @Test String returnsValue() { }     // non-void is a configuration error

  @Nested
  class WhenPaid {                       // must be inner AND @Nested
    @Test void marksShipped() { }
  }
}

// Wrong import, invisible to Jupiter:
// import org.junit.Test;              <-- JUnit 4
// import org.junit.jupiter.api.Test;  <-- correct

Key Points

  • Not private, not static, returns void; public is not required
  • Inner test classes must be annotated @Nested
  • Wrong Test import plus no Vintage engine equals invisible tests
  • Check engine on runtime classpath, Surefire naming, useJUnitPlatform, tag filters
Q14

Write a parameterized test with @ValueSource and explain what @ValueSource cannot express.

BasicParameterized Tests

Answer

@ParameterizedTest replaces @Test and must be paired with at least one argument source. @ValueSource is the simplest: it takes a single array of compile-time constants through one of its attributes (strings, ints, longs, doubles, floats, chars, booleans, shorts, bytes or classes) and runs the method once per value. The parameterized support lives in junit-jupiter-params, which the junit-jupiter aggregator pulls in automatically but a hand-rolled dependency list often misses, producing a compile error on the annotation import. Its limits matter. @ValueSource supplies exactly one argument, so a test needing an input and an expected output must use @CsvSource or @MethodSource.

Values must be compile-time constants, so you cannot compute them or read them from a file. It cannot express null, because annotation attributes cannot hold it, which is why @NullSource and @NullAndEmptySource exist and can be stacked on the same method. And an empty source is a configuration error, not a pass.

Two behaviours interviewers probe: each invocation is a full test with its own lifecycle, so @BeforeEach runs before every value rather than once, and a fresh test instance is created per invocation under the default lifecycle. That is what keeps parameterized cases independent, and it is also why heavy per-test setup multiplies the cost by the number of arguments.

@ParameterizedTest(name = "{index} => phone={0}")
@ValueSource(strings = {"9810012345", "+919810012345", "09810012345"})
void acceptsIndianMobileFormats(String input) {
  assertTrue(PhoneValidator.isValidIndianMobile(input));
}

@ParameterizedTest
@NullAndEmptySource                       // covers null and ""
@ValueSource(strings = {" ", "\t", "abc", "12345"})
void rejectsGarbage(String input) {
  assertFalse(PhoneValidator.isValidIndianMobile(input));
}
💡 Pro Tip: Stack multiple sources on one @ParameterizedTest, they concatenate rather than conflict.
Q15

What problem do @Nested test classes solve, and what are their constraints?

BasicTest Structure

Answer

@Nested lets you express context inside a test class. Instead of ten methods named shouldRejectWhenCartEmptyAndCouponExpired, you get an inner class WhenCouponIsExpired containing three short methods, with the shared setup for that context in its own @BeforeEach. The nested instance can see the outer instance's fields and inherits the outer lifecycle callbacks, so the setup composes: outer @BeforeEach builds the base fixture, inner @BeforeEach mutates it into the specific scenario.

Reports and IDE trees render the hierarchy, and combined with @DisplayName or the IndicativeSentences generator the output reads like a specification. The constraints: the class must be a non-static inner class annotated @Nested, a static nested class is treated as an ordinary top-level test class and does not inherit anything. Historically @BeforeAll inside a @Nested class was impossible because Java forbade static members in inner classes, and the workaround was @TestInstance(Lifecycle.PER_CLASS); on Java 16 and later, static members in inner classes are legal so a static @BeforeAll now compiles.

Keep the depth sane, two levels is usually the readable maximum and three is where people start losing track of which setup ran. The other caution is that a shared mutable field on the outer class combined with nested classes reintroduces coupling in a way that is harder to spot than a plain static field.

@DisplayName("Checkout")
class CheckoutTest {

  Cart cart;

  @BeforeEach void baseFixture() { cart = new Cart(user("saksham")); }

  @Nested
  @DisplayName("when the cart is empty")
  class WhenEmpty {
    @Test void rejectsCheckout() {
      assertThrows(EmptyCartException.class, () -> cart.checkout());
    }
  }

  @Nested
  @DisplayName("when a coupon is applied")
  class WithCoupon {
    @BeforeEach void addCoupon() { cart.add(item("GOLD", 4999)); cart.apply("FLAT500"); }

    @Test void discountsTheTotal() { assertEquals(4499, cart.totalRupees()); }
    @Test void rejectsSecondCoupon() {
      assertThrows(CouponAlreadyAppliedException.class, () -> cart.apply("FLAT100"));
    }
  }
}
Q16

How does @Tag work, and how do you filter by tag in Maven, Gradle and tag expressions?

BasicTest Selection

Answer

@Tag attaches a label to a class or method, and the Platform can then include or exclude by label at discovery time, which is cheaper and more reliable than filtering by class name. Tags are trimmed strings that must not be blank and must not contain whitespace, ISO control characters, or any of the reserved characters used by tag expressions: comma, parentheses, ampersand, pipe and exclamation mark. In Maven, Surefire and Failsafe expose -Dgroups and -DexcludedGroups, and both accept full tag expressions such as "integration & !slow".

In Gradle you pass includeTags and excludeTags inside useJUnitPlatform. IDEs let you run by tag directly from the run configuration. The pattern most teams settle on is a small controlled vocabulary (fast, slow, integration, flaky, security) enforced by constants rather than raw strings, because typos in tag names fail open: a misspelt tag simply never matches an exclusion and the slow test runs in the PR pipeline anyway.

The better version of this is a composed annotation. Because @Tag is meta-annotatable, you can define @IntegrationTest that bundles @Tag("integration"), @ExtendWith(SomeExtension.class) and a @Timeout in one place, then apply the single annotation. That also gives you one file to change when the convention evolves, instead of a project-wide find and replace.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
@Timeout(value = 30, unit = TimeUnit.SECONDS)
public @interface IntegrationTest { }

@IntegrationTest
class PaymentGatewayIT {
  @Test void chargesRealSandboxCard() { }
}

// Maven
// mvn test -Dgroups="fast"
// mvn verify -Dgroups="integration & !slow"

// Gradle
// tasks.test { useJUnitPlatform { includeTags("fast") } }

Key Points

  • Tags cannot contain whitespace or , ( ) & | ! characters
  • -Dgroups and -DexcludedGroups in Surefire and Failsafe accept tag expressions
  • includeTags / excludeTags inside useJUnitPlatform for Gradle
  • Wrap tags in composed annotations so the vocabulary stays typo-proof
Q17

Where does JUnit sit between unit and integration testing, and how do you keep the unit suite fast?

BasicTest Strategy

Answer

JUnit is a harness, not a category. The same @Test annotation runs a three-microsecond pure-function check and a ninety-second Testcontainers scenario, and the discipline of separating them is entirely yours. A unit test in a Java codebase should construct the class under test with plain new, stub its collaborators, touch no database, no network, no filesystem outside @TempDir, no Spring context and no clock, and finish in single-digit milliseconds.

An integration test may boot a slice of Spring, a real Postgres in a container, or an embedded broker, and belongs in a class named *IT running under Failsafe so it never blocks the fast feedback loop. The practical rule of thumb: if a supposedly unit test takes 300ms, something booted a context or opened a socket. A few thousand real unit tests should finish in a couple of minutes on a laptop, and if yours takes fifteen the cause is almost always @SpringBootTest sprinkled across classes that needed nothing more than a constructor.

In Indian product teams the common shape is that the unit suite gates every pull request and the integration suite runs on merge and nightly, because CI minutes are a real budget line. When an interviewer asks for the right ratio between layers, the answer they respect is not a percentage, it is that the fast layer must stay fast enough that developers run it before pushing.

Key Points

  • JUnit runs both layers, the separation is a convention you enforce
  • Unit tests: plain constructors, stubbed collaborators, no context, no I/O
  • Integration tests: *IT class names, Failsafe, containers, verify phase
  • A 300ms unit test usually means an accidental Spring context
Q18

How does the @Timeout annotation behave, and why can a hanging test still hang your build?

BasicTimeouts

Answer

@Timeout can be placed on a test method, a lifecycle method or a whole class, and takes a value plus a TimeUnit. Defaults are configurable globally with junit.jupiter.execution.timeout.default, with finer keys such as junit.jupiter.execution.timeout.testable.method.default and junit.jupiter.execution.timeout.beforeeach.method.default, which is a cheap way to stop any test in a large suite from running forever. The behaviour that surprises people: by default the test executes on the calling thread and JUnit only reports the timeout after the method returns.

If your code is stuck in an uninterruptible socket read or an infinite loop, @Timeout does not rescue you, the build hangs until the CI job's own wall-clock limit kills it. To get real interruption use @Timeout(value = 2, unit = SECONDS, threadMode = ThreadMode.SEPARATE_THREAD), which runs the test on another thread and interrupts it, and can be set project-wide with junit.jupiter.execution.timeout.thread.mode.default. That mode has its own cost: anything relying on ThreadLocal state, such as a Spring transaction bound to the test thread, a SecurityContext, or an OpenTelemetry scope, will not see it on the new thread.

The same distinction applies to Assertions.assertTimeout, which waits for completion, versus assertTimeoutPreemptively, which aborts on another thread. Use timeouts as a safety net against hangs, never as a performance assertion, because CI runners are noisy neighbours and a 200ms budget will flake.

// junit-platform.properties
// junit.jupiter.execution.timeout.default = 10 s
// junit.jupiter.execution.timeout.testable.method.default = 5 s

@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
void parsesLargeResumeWithinBudget() {
  parser.parse(twoMegabytePdf());     // actually interrupted at 2s
}

@Test
void sameThreadTimeoutOnlyReportsAfterCompletion() {
  // assertTimeout waits for the block to finish, then fails
  assertTimeout(Duration.ofMillis(500), () -> service.compute());

  // assertTimeoutPreemptively aborts on a separate thread (loses ThreadLocals)
  assertTimeoutPreemptively(Duration.ofMillis(500), () -> service.compute());
}
💡 Pro Tip: Never set a tight @Timeout as a latency assertion. Shared CI runners will make it flaky within a week.
Q19

What are the rules for @MethodSource, and what errors will you hit when you get them wrong?

IntermediateParameterized Tests

Answer

@MethodSource names a factory method that returns the arguments. The factory must be static unless the class is annotated @TestInstance(Lifecycle.PER_CLASS), must take no parameters, and must return a Stream, Iterable, Iterator or array. For a single-argument test it can return a stream of that type directly; for multiple parameters it returns a stream of Arguments, built with Arguments.of or the shorter static import arguments().

If you omit the value attribute, JUnit looks for a factory with the same name as the test method, which keeps things tidy. You can also point at another class entirely using the fully qualified form com.acme.TestData#invalidCards, which is how teams share fixture sets across test classes. The errors are recognisable once you have seen them.

"Could not find factory method" means a typo, a non-static method without PER_CLASS, or a method that takes parameters. "Configuration error: You must configure at least one set of arguments for this @ParameterizedTest" means the stream came back empty, which is what happens when the factory filters a list that has changed. A stream returned by the factory is closed by JUnit after the invocations complete, so returning a Files.lines stream is safe. The real advantage over @CsvSource is types: you can build domain objects, records, dates and nested structures instead of parsing strings, and the compiler checks them.

class GstCalculatorTest {

  static Stream<Arguments> slabs() {
    return Stream.of(
        arguments(new Money(1000), GstSlab.FIVE,     new Money(1050)),
        arguments(new Money(1000), GstSlab.TWELVE,   new Money(1120)),
        arguments(new Money(1000), GstSlab.EIGHTEEN, new Money(1180)),
        arguments(new Money(0),    GstSlab.EIGHTEEN, new Money(0)));
  }

  @ParameterizedTest(name = "{0} at {1} => {2}")
  @MethodSource("slabs")
  void appliesSlab(Money base, GstSlab slab, Money expected) {
    assertEquals(expected, calculator.withGst(base, slab));
  }

  // shared fixtures from another class
  @ParameterizedTest
  @MethodSource("com.acme.fixtures.CardData#expiredCards")
  void rejectsExpired(Card card) {
    assertFalse(validator.isChargeable(card));
  }
}

Key Points

  • Factory must be static (or the class must be PER_CLASS) and take no parameters
  • Return Stream/Iterable/Iterator/array, of Arguments for multi-parameter tests
  • Omit the value attribute to use a factory with the same name as the test
  • Cross-class reference uses fully.qualified.ClassName#methodName
  • Empty stream is a configuration error, not a passing test
Q20

What can @CsvSource and @CsvFileSource do beyond simple comma-separated values?

IntermediateParameterized Tests

Answer

@CsvSource is far more capable than it looks. You can change the separator with delimiter or delimiterString, change the quote character, declare which literal token means null through nullValues, control what an empty quoted field becomes with emptyValue, and switch off whitespace trimming with ignoreLeadingAndTrailingWhitespace. The attribute worth knowing in 2026 is textBlock, which accepts a Java text block so the table is laid out readably with a header row, and it handles multi-line data far better than an array of quoted strings. @CsvFileSource reads from classpath resources or files with numLinesToSkip for headers, an encoding attribute, and the same null and quote handling, which is the right choice once a table exceeds twenty rows or is maintained by someone who is not the test author.

Both rely on implicit argument conversion: a String is converted to the declared parameter type automatically for primitives, enums, UUID, File, Path, the java.time types, and any type that has a single String constructor or a static factory named valueOf or of. When that is not enough, @ConvertWith with a custom ArgumentConverter handles bespoke parsing, and @AggregateWith with an ArgumentsAggregator (or the ArgumentsAccessor parameter) collapses a wide row into one domain object so your test signature does not grow to eight parameters. The common failure is a comma inside a data value, which shifts every subsequent column, so quote such fields or switch the delimiter.

@ParameterizedTest
@CsvSource(nullValues = "NULL", textBlock = """
    pincode,  city,     serviceable
    110044,   Delhi,    true
    201301,   Noida,    true
    999999,   NULL,     false
    """)
void resolvesServiceability(String pincode, String city, boolean serviceable) {
  Serviceability s = service.lookup(pincode);
  assertEquals(serviceable, s.isServiceable());
  assertEquals(city, s.city());
}

@ParameterizedTest
@CsvFileSource(resources = "/salary-bands.csv", numLinesToSkip = 1)
void mapsSalaryBand(String role, int years, @ConvertWith(LpaConverter.class) Lpa expected) {
  assertEquals(expected, bandService.band(role, years));
}
💡 Pro Tip: Use textBlock with a header row. A reviewer can then read the table without counting commas.
Q21

How do @NullSource, @EmptySource and @EnumSource behave, and where do they fail?

IntermediateParameterized Tests

Answer

@NullSource supplies a single null argument and cannot be used on a primitive parameter, where it fails with a conversion error at runtime rather than at compile time. @EmptySource supplies an empty value for the parameter type it understands: an empty String, an empty array, an empty List, Set, Map or Stream. @NullAndEmptySource is the shorthand for both, and all of them stack with other sources on the same method, with arguments concatenated in declaration order, which is how you write one test that covers null, blank and a handful of malformed inputs. @EnumSource is the underrated one. With no attributes it runs the test once for every constant of the enum type declared as the parameter, which means the day someone adds a new constant your test automatically exercises it, and any switch statement missing a branch fails immediately. That single property catches more real bugs than most parameterized tests.

Narrow it with names plus mode: Mode.INCLUDE is the default, Mode.EXCLUDE runs everything except the listed constants (better than INCLUDE, because a new constant is still covered), and Mode.MATCH_ALL or MATCH_ANY treat names as regular expressions. If your enum has behaviour attached, an @EnumSource test asserting an invariant across every constant is the cheapest safety net in the codebase.

enum PayoutStatus { INITIATED, PROCESSING, SETTLED, FAILED, REVERSED }

@ParameterizedTest
@EnumSource                       // every constant, including ones added later
void everyStatusHasADisplayLabel(PayoutStatus status) {
  assertNotNull(status.label(), status + " has no label");
}

@ParameterizedTest
@EnumSource(value = PayoutStatus.class, names = {"SETTLED", "REVERSED"}, mode = Mode.EXCLUDE)
void nonTerminalStatusesAreRetryable(PayoutStatus status) {
  assertTrue(retryPolicy.isRetryable(status));
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "   "})
void blankPanIsRejected(String pan) {
  assertFalse(PanValidator.isValid(pan));
}
Q22

What is @RepeatedTest good for, and why is it a poor tool for proving you fixed a flaky test?

IntermediateTest Repetition

Answer

@RepeatedTest(10) runs the same method ten times as ten separate tests, each with a full lifecycle, and supports a name template with {currentRepetition} and {totalRepetitions}. You can inject RepetitionInfo to branch on which run you are in, and recent versions added a failureThreshold attribute so a repeated test can tolerate a number of failures before the whole thing is marked failed, which is useful for genuinely probabilistic checks such as a sampler or a rate limiter. Legitimate uses: warming a JIT-sensitive benchmark-ish check, exercising a random-input generator a few dozen times, or confirming that a method is idempotent across invocations.

It is a poor flakiness detector because it repeats under identical conditions. The same JVM, the same thread, the same warmed caches, the same clock, the same ordering, the same database rows. Real flakiness comes from things repetition holds constant: interleaving with other tests, a different execution order, contention on a shared table, a clock crossing a boundary, a container that starts slower on a loaded runner.

Worse, repetitions share static state, so repetition three can pass only because repetition two populated a cache, which is precisely the coupling you were hunting. The tools that actually surface flakiness are randomised method and class ordering, parallel execution, running the suite on a constrained CI runner, and reruns across separate JVM forks.

@RepeatedTest(value = 20, name = "attempt {currentRepetition} of {totalRepetitions}")
void otpGeneratorProducesSixDigits(RepetitionInfo info) {
  String otp = generator.next();
  assertEquals(6, otp.length(), "bad otp on repetition " + info.getCurrentRepetition());
  assertTrue(otp.chars().allMatch(Character::isDigit));
}

// tolerate a small number of failures for a genuinely probabilistic check
@RepeatedTest(value = 100, failureThreshold = 2)
void samplerKeepsRoughlyTenPercent() {
  assertTrue(sampler.shouldSample() || true);
}
💡 Pro Tip: If a test only fails in CI, reproduce it by randomising order and running the suite in parallel, not by repeating one method.
Q23

When do you use @TestFactory and dynamic tests instead of @ParameterizedTest?

IntermediateDynamic Tests

Answer

A @TestFactory method returns a Stream, Collection, Iterator or Iterable of DynamicNode, built from DynamicTest.dynamicTest(displayName, executable) and optionally grouped with DynamicContainer.dynamicContainer. The cases are produced at runtime, so you can generate one test per file in a golden-output directory, one per row returned by a query, one per endpoint in an OpenAPI document, or one per JSON fixture checked into the repo. That is the deciding factor: @ParameterizedTest needs its cases resolvable through annotations, while @TestFactory builds them from whatever the code can compute when the factory runs.

The catch that interviewers look for is lifecycle. @BeforeEach and @AfterEach run once around the entire factory method, not around each dynamic test, and extensions do not wrap individual dynamic tests either. So if each case needs a clean database or a fresh mock, dynamic tests are the wrong tool and you should use @ParameterizedTest or @TestTemplate, which do get full per-invocation lifecycle. Dynamic tests also cannot be filtered individually by tag, and IDE re-run of a single case is limited. Use them where the case list is genuinely dynamic and the assertions are self-contained, for example a contract test that walks every .json file under src/test/resources/contracts and asserts each one still deserialises and round-trips.

@TestFactory
Stream<DynamicTest> everyGoldenFileStillParses() throws IOException {
  return Files.list(Path.of("src/test/resources/resumes"))
      .filter(p -> p.toString().endsWith(".json"))
      .map(path -> dynamicTest("parses " + path.getFileName(), () -> {
        Resume parsed = mapper.readValue(path.toFile(), Resume.class);
        assertNotNull(parsed.candidateName());
        assertEquals(Files.readString(path).trim(),
            mapper.writeValueAsString(parsed).trim());
      }));
}

@TestFactory
List<DynamicNode> groupedByCity() {
  return List.of(
      dynamicContainer("Delhi NCR", Stream.of(
          dynamicTest("110044 serviceable", () -> assertTrue(svc.check("110044"))),
          dynamicTest("201301 serviceable", () -> assertTrue(svc.check("201301"))))));
}

Key Points

  • Cases are built at runtime, not declared in annotations
  • @BeforeEach runs once for the whole factory, not per dynamic test
  • Extensions do not wrap individual dynamic tests
  • Use @ParameterizedTest or @TestTemplate when you need per-case lifecycle
Q24

How does @TempDir work, what are its cleanup modes, and why does it fail on Windows?

IntermediateFilesystem Testing

Answer

@TempDir injects a freshly created temporary directory as a Path or File, either as a test method parameter, a constructor parameter, or a field. As an instance field it is created per test method; as a static field it is created once per class and shared, which is what you want for an expensive fixture but which also reintroduces cross-test coupling. JUnit deletes the directory recursively after the scope ends, so you never write cleanup code and never leave junk in the CI runner's disk, unlike File.deleteOnExit which never fires when a container is killed.

The cleanup attribute is the part people miss. CleanupMode.ON_SUCCESS keeps the directory when the test fails, so you can open the generated file and see what actually went wrong instead of adding print statements and rerunning. CleanupMode.NEVER keeps it always, and the project-wide default is set with junit.jupiter.tempdir.cleanup.mode.default.

The classic Windows failure is a message about failing to delete the temp directory, caused by a stream, a FileChannel, a ZipFile or a memory-mapped buffer still open when the test ends, because Windows refuses to unlink an open file while Linux happily does. That means the same test passes on a developer's Mac and fails on a Windows build agent, which is still a common setup in Indian enterprise and banking projects. Wrap every stream in try-with-resources and the problem disappears.

class ReportWriterTest {

  @Test
  void writesCsvHeader(@TempDir(cleanup = CleanupMode.ON_SUCCESS) Path dir) throws IOException {
    Path out = dir.resolve("payouts-2026-07.csv");

    try (Writer w = Files.newBufferedWriter(out)) {   // must close before the test ends
      new ReportWriter().write(w, payouts());
    }

    List<String> lines = Files.readAllLines(out);
    assertEquals("id,amount_paise,status", lines.get(0));
    assertEquals(3, lines.size() - 1);
  }
}

// junit-platform.properties
// junit.jupiter.tempdir.cleanup.mode.default = ON_SUCCESS
💡 Pro Tip: ON_SUCCESS turns a failing file-generation test into a directory you can open, which is far faster than adding logging.
Q25

How do you control test execution order in JUnit 5, and why is depending on order a defect?

IntermediateExecution Order

Answer

Method order is controlled with @TestMethodOrder plus a MethodOrderer implementation: OrderAnnotation reads @Order(int) on each method, DisplayName and MethodName sort alphabetically, and Random shuffles. Class order is controlled with junit.jupiter.testclass.order.default or @TestClassOrder on an enclosing class, with ClassOrderer.ClassName, DisplayName, OrderAnnotation and Random available. The Random orderers use a seed that JUnit prints to the log and that you can pin with junit.jupiter.execution.order.random.seed, which is what lets you reproduce a shuffled failure exactly.

By default JUnit uses a deterministic algorithm that is intentionally not source order, specifically so nobody accidentally builds a suite that depends on it. Depending on order is a defect because it hides two bugs at once: the test that only passes because an earlier test created a row is not actually testing what it claims, and the earlier test is silently doing setup work that belongs in @BeforeEach. Such a suite breaks the moment someone reorders methods, enables parallel execution, or runs a single test from the IDE, and the failure looks like a product bug rather than a test bug.

There are honest uses for ordering: running the cheapest tests first so a broken build fails in ten seconds, or a deliberately sequential end-to-end scenario documented as such. A useful hygiene practice is a weekly CI job that runs the suite with Random ordering; anything that fails there is coupled.

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OnboardingScenarioTest {
  @Test @Order(1) void createsAccount() { }
  @Test @Order(2) void verifiesOtp() { }
  @Test @Order(3) void completesProfile() { }
}

// junit-platform.properties: shuffle to expose hidden coupling
// junit.jupiter.testmethod.order.default = \
//   org.junit.jupiter.api.MethodOrderer$Random
// junit.jupiter.testclass.order.default = \
//   org.junit.jupiter.api.ClassOrderer$Random
// junit.jupiter.execution.order.random.seed = 1754870400000

Key Points

  • @TestMethodOrder with OrderAnnotation, DisplayName, MethodName or Random
  • ClassOrderer configured via junit.jupiter.testclass.order.default
  • Random seed is logged and can be pinned to reproduce a shuffle
  • Default order is deterministic but deliberately not declaration order
Q26

How do you enable parallel test execution in JUnit 5, and which configuration keys matter?

IntermediateParallel Execution

Answer

Parallelism is off by default and switched on in junit-platform.properties (or via system properties) with junit.jupiter.execution.parallel.enabled=true. That flag alone changes nothing visible, because the default execution mode is still same_thread; you also set junit.jupiter.execution.parallel.mode.default=concurrent to run methods in parallel, and junit.jupiter.execution.parallel.mode.classes.default=concurrent to run classes in parallel too. A common intermediate configuration is classes concurrent with methods same_thread, which gives most of the wall-clock win while keeping each class internally sequential and therefore far less likely to break.

The thread pool is sized by junit.jupiter.execution.parallel.config.strategy, which is dynamic by default (available processors multiplied by config.dynamic.factor), fixed (config.fixed.parallelism, plus a max-pool-size key in recent versions), or custom with your own ParallelExecutionConfigurationStrategy. Per-class and per-method overrides use @Execution(ExecutionMode.SAME_THREAD) or CONCURRENT, @Isolated forces a class to run alone while nothing else runs, and @ResourceLock declares a named resource with READ or READ_WRITE mode so JUnit serialises only the tests that contend. Built-in resource keys cover Resources.SYSTEM_PROPERTIES, SYSTEM_OUT, SYSTEM_ERR and LOCALE.

The payoff on a real suite is large, a twelve-minute run can drop to three on an eight-core runner, and Gradle's maxParallelForks composes on top by adding process-level parallelism. The cost is that every latent shared-state bug surfaces at once, which is the subject interviewers move to next.

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = dynamic
junit.jupiter.execution.parallel.config.dynamic.factor = 1.0

// opt one class out
@Execution(ExecutionMode.SAME_THREAD)
class LegacySingletonTest { }

// serialise only the tests that touch the same resource
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE)
@Test void togglesFeatureFlagProperty() { }

@Isolated("mutates the shared in-memory cache")
class CacheEvictionTest { }
💡 Pro Tip: Start with classes concurrent and methods same_thread. It captures most of the speedup at a fraction of the risk.
Q27

How do you wire Mockito into JUnit 5, and what does UnnecessaryStubbingException actually tell you?

IntermediateMocking

Answer

Add mockito-junit-jupiter and annotate the class with @ExtendWith(MockitoExtension.class). The extension initialises @Mock, @Spy and @Captor fields before each test, resolves @Mock parameters injected into test methods, and validates the mocks afterwards. @InjectMocks constructs the class under test and pushes the mocks in, trying constructor injection first, then property setters, then field injection. MockitoExtension defaults to Strictness.STRICT_STUBS, which does three valuable things: a stub that no test code ever calls fails the test with UnnecessaryStubbingException, a call that misses a stub because the arguments do not match produces a PotentialStubbingProblem naming the mismatch instead of quietly returning null, and it reports these at the end of the test rather than at some distant NPE.

UnnecessaryStubbingException almost always means one of two things: the production code path changed and no longer calls that collaborator, so the stub is dead and should be deleted, or the test is stubbing something it does not need, which is a sign the test knows too much about the implementation. Loosening it with @MockitoSettings(strictness = Strictness.LENIENT) or lenient().when(...) should be rare and deliberate, typically for a shared @BeforeEach stub used by only some tests in the class. Worth saying in an interview: @InjectMocks fails quietly when a constructor gains a parameter you have no mock for, injecting null, so many teams skip it and call the constructor explicitly.

@ExtendWith(MockitoExtension.class)
class PayoutServiceTest {

  @Mock PayoutRepository repository;
  @Mock GatewayClient gateway;
  @Captor ArgumentCaptor<PayoutRequest> requestCaptor;

  PayoutService service;

  @BeforeEach
  void setUp() {
    // explicit construction beats @InjectMocks: a new ctor param breaks compilation
    service = new PayoutService(repository, gateway, Clock.systemUTC());
  }

  @Test
  void sendsNetAmountToGateway() {
    when(repository.findById(7L)).thenReturn(Optional.of(payout(10_000L)));

    service.execute(7L);

    verify(gateway).send(requestCaptor.capture());
    assertEquals(9_800L, requestCaptor.getValue().amountPaise());
  }
}

Key Points

  • mockito-junit-jupiter + @ExtendWith(MockitoExtension.class)
  • STRICT_STUBS is the default and fails on unused or mismatched stubs
  • The fix for UnnecessaryStubbingException is usually deleting the stub
  • @InjectMocks can silently inject null when a constructor changes
Q28

When do you use doReturn().when() instead of when().thenReturn(), and how do spies differ from mocks?

IntermediateMocking

Answer

when(mock.method()).thenReturn(value) reads better and is the default, but it works by actually invoking the method on the object so Mockito can record the call. On a plain mock that is harmless because the method is a no-op stub. On a @Spy, which wraps a real instance and calls real code unless stubbed, when(spy.charge(req)) executes the real charge before the stub is registered, which can hit a database, throw, or take a payment. doReturn(value).when(spy).charge(req) avoids that because the stubbing is configured before any invocation.

The same applies when stubbing a void method (doNothing, doThrow, doAnswer) and when stubbing a method whose return type makes the compiler unhappy with generics. Spies are useful for partial mocking of legacy classes you cannot restructure, but every spy in a codebase is a small confession that the design has a seam missing, and interviewers will ask why you needed one. On verification: verify(mock).save(x) checks exactly one call, times(n), never(), atLeastOnce() and atMost() adjust the count, InOrder checks sequencing across mocks, and verifyNoMoreInteractions is powerful but makes tests brittle because any new incidental call breaks them. Argument matchers cannot be mixed with raw values, either all arguments are matchers or none, which is why any(), eq() and argThat() travel together, and the error message "Invalid use of argument matchers, 2 matchers expected, 1 recorded" is exactly that mistake.

@Spy PaymentService realService = new PaymentService(realRepo, realGateway);

@Test
void retriesOnceOnTimeout() {
  // when(realService.callGateway(req)) would ACTUALLY call the gateway first
  doThrow(new GatewayTimeoutException())
      .doReturn(Receipt.ok("rcpt_1"))
      .when(realService).callGateway(any(PaymentRequest.class));

  Receipt receipt = realService.charge(request());

  assertEquals("rcpt_1", receipt.id());
  verify(realService, times(2)).callGateway(any());
}

@Test
void matcherRules() {
  // all matchers or none: eq() is required alongside any()
  when(repo.find(eq(7L), any(Status.class))).thenReturn(Optional.empty());
  verify(auditor, never()).record(argThat(e -> e.severity() == HIGH));
}
Q29

How do you mock static methods and final classes in 2026, and what is the JDK agent warning about?

IntermediateMocking

Answer

Mockito 5 made the inline mock maker the default, so mocking final classes, final methods, static methods and constructors works with plain mockito-core, and the separate mockito-inline artifact plus PowerMock are no longer needed. Mockito.mockStatic(SomeUtil.class) returns a MockedStatic that is scoped to the current thread and must be closed, so always use try-with-resources. Forgetting to close leaks the static mock into every later test on that thread, producing failures in unrelated classes that are miserable to trace, and two tests mocking the same static concurrently will fail outright, which is why static mocking and parallel execution fight each other. mockConstruction intercepts objects created with new inside the method under test, useful for legacy code that instantiates its own collaborators.

The 2026 operational detail interviewers like: the inline mock maker attaches a Java agent at runtime, and modern JDKs warn about dynamic agent loading and are moving toward disallowing it by default, so builds print a warning or fail until you pass the Mockito agent explicitly through the Surefire argLine or set the JVM flag to permit dynamic loading. Configure it once in the build rather than letting every developer see the warning. The better answer to the underlying question is design: inject a Clock instead of calling Instant.now(), inject a Supplier<UUID>, and wrap third-party statics behind a small interface. Then you never need static mocking at all.

@Test
void usesFrozenClockWithoutStaticMocking() {
  // preferred: inject the dependency
  Clock clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC);
  assertEquals(LocalDate.of(2026, 8, 11), new InvoiceService(clock).today());
}

@Test
void staticMockingWhenYouHaveNoChoice() {
  try (MockedStatic<LegacyIdUtil> mocked = mockStatic(LegacyIdUtil.class)) {
    mocked.when(LegacyIdUtil::nextId).thenReturn("GS-000042");

    assertEquals("GS-000042", new OrderFactory().create().id());
    mocked.verify(LegacyIdUtil::nextId, times(1));
  }   // closed here, or it leaks into every later test on this thread
}

<!-- pom.xml: attach the Mockito agent explicitly on modern JDKs -->
<configuration>
  <argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
💡 Pro Tip: Every mockStatic call is technical debt. Write down what refactoring would remove it before you merge.
Q30

Jupiter Assertions, Hamcrest or AssertJ: how do you choose, and what does each cost you?

IntermediateAssertion Libraries

Answer

Jupiter's Assertions class is deliberately minimal: equality, truth, nullness, throws, timeouts, grouping. It has no dependency beyond JUnit and its failure output is a plain expected and actual, which is fine for scalars and poor for collections and objects. Hamcrest is the matcher library JUnit 4 bundled, using assertThat(actual, is(closeTo(x, delta))).

It survives mainly in Spring's MockMvc result matchers and in older codebases; its failure messages are decent but the static-import surface is large and composing matchers is verbose. AssertJ is what most Java teams standardise on in 2026: a single entry point assertThat(actual) followed by IDE-discoverable chained methods, plus specialised assertions for collections, maps, optionals, exceptions, files, dates and streams. Its strongest features in real work are assertThatThrownBy for exception assertions with fluent message checks, extracting and flatExtracting for collections of objects, satisfies for grouped conditions, and usingRecursiveComparison which compares two objects field by field and prints a diff, ignoring the fields you name.

That single method removes most of the boilerplate around DTO and mapper tests without forcing you to write equals on domain classes. The real cost of all this is inconsistency, a codebase with all three libraries mixed makes reviews harder and failure output unpredictable. Pick one, put it in the team convention document, and use Jupiter's Assertions only for assertThrows and assertAll where they are genuinely convenient.

// Jupiter
assertEquals(3, results.size());

// Hamcrest
assertThat(results, hasSize(3));

// AssertJ
assertThat(results)
    .hasSize(3)
    .extracting(Candidate::city)
    .containsExactlyInAnyOrder("Noida", "Pune", "Bengaluru");

assertThatThrownBy(() -> gateway.charge(expiredCard()))
    .isInstanceOf(CardDeclinedException.class)
    .hasMessageContaining("expired")
    .hasFieldOrPropertyWithValue("code", "CARD_EXPIRED");

assertThat(mapper.toDto(entity))
    .usingRecursiveComparison()
    .ignoringFields("createdAt", "updatedAt")
    .isEqualTo(expectedDto);
Q31

How do @SpringBootTest and the test slices differ, and why does context caching decide your suite runtime?

IntermediateSpring Testing

Answer

@SpringBootTest boots the full application context, with webEnvironment MOCK by default (no real server, MockMvc available) or RANDOM_PORT for a real listener plus TestRestTemplate or WebTestClient. The slices load a narrow subset: @WebMvcTest brings up the MVC layer with your controller, converters and filters but no repositories, @DataJpaTest brings up JPA with an in-memory or configured datasource and wraps each test in a transaction that rolls back, @JsonTest exercises serialisation, @RestClientTest exercises outbound HTTP clients. Slices are faster and force better design, because a controller test that needs the whole context usually means the controller is doing too much.

The performance lever nobody sees until the suite hits ten minutes is context caching. Spring caches an ApplicationContext per unique configuration key, made up of the test classes and configuration, active profiles, property sources, web environment and the set of mocked beans. Every distinct combination boots a new context, and every boot is seconds.

That is why one-off @TestPropertySource values, sprinkling @DirtiesContext, or mocking a different bean in each class quietly multiplies your build time. Standardise on a small number of context shapes and reuse them. Note also that @MockBean is deprecated in favour of @MockitoBean (Spring Framework 6.2 and Boot 3.4 onward), and that @DataJpaTest rolls back by default, which hides constraint violations that only occur on commit unless you call flush explicitly.

@WebMvcTest(CandidateController.class)
class CandidateControllerTest {

  @Autowired MockMvc mvc;
  @MockitoBean CandidateService service;      // @MockBean is deprecated

  @Test
  void returns404ForUnknownCandidate() throws Exception {
    when(service.findById(99L)).thenReturn(Optional.empty());

    mvc.perform(get("/api/candidates/99"))
       .andExpect(status().isNotFound());
  }
}

@DataJpaTest
class CandidateRepositoryTest {

  @Autowired TestEntityManager em;
  @Autowired CandidateRepository repo;

  @Test
  void enforcesUniqueEmail() {
    em.persistAndFlush(candidate("a@goodspace.ai"));
    assertThrows(DataIntegrityViolationException.class,
        () -> em.persistAndFlush(candidate("a@goodspace.ai")));  // flush forces the constraint
  }
}

Key Points

  • @SpringBootTest boots everything, slices boot a layer
  • Contexts are cached per unique configuration key, including mocked beans
  • @DirtiesContext and ad-hoc property overrides fragment the cache and slow the build
  • @MockitoBean replaces the deprecated @MockBean in current Spring versions
  • @DataJpaTest rolls back, so flush to see commit-time constraint failures
Q32

How do you wire Testcontainers into a JUnit 5 suite, and what breaks in CI?

IntermediateIntegration Testing

Answer

Add the junit-jupiter Testcontainers module, annotate the class with @Testcontainers, and declare the container in a field annotated @Container. A static @Container starts once for the class and stops after it, an instance field starts and stops per test method, which is almost always the wrong choice because container startup dominates the runtime. Wiring the connection details into Spring used to require @DynamicPropertySource; since Spring Boot 3.1, annotating the container bean or field with @ServiceConnection derives the JDBC URL, username and password automatically for supported images.

Keep these classes named *IT and run them under Failsafe so a crash still hits post-integration-test teardown. The CI failures follow a pattern. There must be a usable Docker daemon: GitHub Actions Linux runners have one, self-hosted Kubernetes runners often need a Docker-in-Docker sidecar or a rootless setup, and that is where most Indian teams running their own GitLab runners get stuck.

Image pull time dominates a cold run, so pre-pull images into the runner image and pin tags to digests so a moved tag does not change behaviour overnight. The Ryuk reaper container that cleans up leftovers is blocked in some hardened environments, and TESTCONTAINERS_RYUK_DISABLED=true is the escape hatch, at the price of leaked containers if a job is killed. Reuse (testcontainers.reuse.enable in ~/.testcontainers.properties plus withReuse(true)) speeds up local development and should not be relied on in CI.

@Testcontainers
@SpringBootTest
class CandidateSearchIT {

  @Container
  @ServiceConnection                      // Spring Boot 3.1+, no @DynamicPropertySource
  static PostgreSQLContainer<?> postgres =
      new PostgreSQLContainer<>("postgres:16-alpine")
          .withReuse(true);

  @Autowired CandidateRepository repo;

  @Test
  void findsByCityCaseInsensitively() {
    repo.save(candidate("Noida"));
    assertEquals(1, repo.findByCityIgnoreCase("noida").size());
  }
}

// static container = one start per class; drop static and it starts per test method
💡 Pro Tip: A container field that is not static is the most common reason an integration suite takes twenty minutes.
Q33

What can JUnit inject into test constructors and methods without any extra library?

IntermediateParameter Resolution

Answer

Jupiter resolves parameters through the ParameterResolver extension point, and it registers a few resolvers out of the box. TestInfo gives you the display name, the tags, and Optionals for the test class and test method, so a logging or diagnostic helper can identify the current test without reflection. TestReporter has publishEntry, which writes key-value pairs into the Surefire XML and the Gradle report, and it is the correct way to attach diagnostic data such as a generated order id, a container port, or a correlation id to a test result instead of printing to stdout where CI truncates it.

RepetitionInfo is available inside @RepeatedTest. @TempDir resolves a Path or File. All four work as constructor parameters too, which is why a Jupiter test class may declare a constructor rather than fields. Everything else needs a resolver supplied by an extension: SpringExtension resolves autowired parameters, MockitoExtension resolves @Mock parameters, and a custom resolver of your own can inject a seeded fixture, an authenticated user, or a per-test tenant id. The rule is that a resolver must answer supportsParameter for a given ParameterContext and then produce the value in resolveParameter, and if two registered resolvers both claim the same parameter JUnit fails fast with a ParameterResolutionException rather than picking one, which is a good thing and occasionally a confusing one when two libraries overlap.

class DiagnosticsTest {

  private final TestInfo info;

  DiagnosticsTest(TestInfo info) {          // constructor injection works
    this.info = info;
  }

  @Test
  @Tag("payments")
  void publishesDiagnostics(TestReporter reporter, @TempDir Path work) {
    reporter.publishEntry("displayName", info.getDisplayName());
    reporter.publishEntry("tags", String.join(",", info.getTags()));
    reporter.publishEntry("workDir", work.toString());

    assertTrue(info.getTestMethod().isPresent());
  }
}
Q34

What is the difference between @ExtendWith and @RegisterExtension, and how are extensions ordered?

IntermediateExtension Model

Answer

@ExtendWith is declarative: you name the extension class and JUnit instantiates it with its no-argument constructor. It can go on a class, a method, a parameter, or on another annotation as a meta-annotation, which is how composed annotations such as Spring's @SpringBootTest carry their extension along. @RegisterExtension is programmatic: you declare a field holding an instance you constructed yourself, so the extension can be configured with a port, a base URL, a fixture set, or anything else you compute. You can also read state back from the field inside the test, which is how WireMock and similar libraries expose the running server.

Static @RegisterExtension fields participate in class-level callbacks (BeforeAllCallback and AfterAllCallback), instance fields only in method-level ones, which trips people up when a programmatic extension appears to do nothing. Ordering matters once you have several. Extensions registered declaratively are applied in the order they appear, before callbacks run top-down in registration order and after callbacks unwind bottom-up, so cleanup happens in reverse of setup.

Programmatic field extensions are registered after declarative ones and their relative order is controlled with @Order on the field, since Java does not guarantee field order. There is also automatic registration through the ServiceLoader mechanism, enabled with junit.jupiter.extensions.autodetection.enabled=true, which libraries use to install themselves with zero configuration; keep it off unless you know exactly which jars are on the test classpath.

@ExtendWith(MockitoExtension.class)          // declarative, no configuration
class NotificationServiceTest {

  @RegisterExtension
  @Order(1)
  static WireMockExtension gupshup = WireMockExtension.newInstance()
      .options(wireMockConfig().dynamicPort())
      .build();                                // configured instance, class-level

  @RegisterExtension
  SeedDatabaseExtension seed = new SeedDatabaseExtension("fixtures/users.sql"); // per method

  @Test
  void postsWhatsappTemplate() {
    gupshup.stubFor(post("/wa/messages").willReturn(ok()));
    new NotificationService(gupshup.baseUrl()).notifyCandidate(42L);
    gupshup.verify(postRequestedFor(urlEqualTo("/wa/messages")));
  }
}

Key Points

  • @ExtendWith is declarative and needs a no-arg constructor
  • @RegisterExtension holds a configured instance you can query in the test
  • Static fields get class-level callbacks, instance fields only method-level
  • Before callbacks run in registration order, after callbacks in reverse
Q35

Write a custom JUnit 5 extension that seeds a database before each test and injects a fixture.

IntermediateExtension Model

Answer

A custom extension implements one or more callback interfaces and is registered with @ExtendWith or @RegisterExtension. The useful ones are BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback, ParameterResolver, ExecutionCondition, TestExecutionExceptionHandler and InvocationInterceptor. An extension instance is not guaranteed to be per test, so never keep mutable per-test state in a field.

Use the ExtensionContext store instead: context.getStore(Namespace.create(getClass(), context.getUniqueId())) gives you a scoped key-value map, and values that implement ExtensionContext.Store.CloseableResource (or AutoCloseable in recent versions) are closed automatically when that scope ends. Combining BeforeEachCallback with ParameterResolver produces the pattern most teams want: the extension prepares the world, then hands the test whatever handle it needs as a method parameter, so the test body contains assertions and nothing else. This is exactly how SpringExtension, MockitoExtension and the Testcontainers extension are built, and writing one yourself is a common senior-level interview exercise because it proves you understand extension lifetime, store scoping, and why extensions are preferred over inheriting from a BaseTest class. Extensions compose, base classes do not: a test can carry five extensions but only one superclass, and the deep test-hierarchy pattern from JUnit 4 codebases is the thing Jupiter was designed to replace.

public class SeedDatabaseExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver {

  private static final Namespace NS = Namespace.create(SeedDatabaseExtension.class);

  @Override
  public void beforeEach(ExtensionContext ctx) throws Exception {
    Connection cn = DriverManager.getConnection(System.getProperty("test.jdbc.url"));
    cn.setAutoCommit(false);
    try (Statement st = cn.createStatement()) {
      st.execute("INSERT INTO users(id, email) VALUES (1, 'saksham@goodspace.ai')");
    }
    ctx.getStore(NS).put(ctx.getUniqueId(), cn);
  }

  @Override
  public void afterEach(ExtensionContext ctx) throws Exception {
    Connection cn = ctx.getStore(NS).remove(ctx.getUniqueId(), Connection.class);
    if (cn != null) { cn.rollback(); cn.close(); }   // rollback keeps tests independent
  }

  @Override
  public boolean supportsParameter(ParameterContext pc, ExtensionContext ctx) {
    return pc.getParameter().getType() == Connection.class;
  }

  @Override
  public Object resolveParameter(ParameterContext pc, ExtensionContext ctx) {
    return ctx.getStore(NS).get(ctx.getUniqueId(), Connection.class);
  }
}
Q36

How do you test time-dependent logic such as token expiry or a cron window?

IntermediateTesting Practices

Answer

Stop calling Instant.now(), LocalDate.now() or System.currentTimeMillis() inside business logic and inject a java.time.Clock instead. In production wire Clock.systemUTC() (or a fixed zone) as a bean; in tests pass Clock.fixed(Instant.parse("2026-08-11T18:30:00Z"), ZoneOffset.UTC) so you can assert boundary behaviour precisely: one second before expiry, exactly at expiry, one second after. Clock.offset and a small mutable test clock let you move time forward without sleeping.

This one refactor removes nearly every reason to reach for static mocking, makes tests deterministic, and takes minutes. For asynchronous work, replace Thread.sleep with Awaitility: await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(50)).untilAsserted(() -> assertEquals(1, repo.count())). A sleep is either too short and flaky or too long and slow, and a suite with two hundred sleeps of one second each is three and a half minutes of pure waiting.

Timezones deserve explicit attention in Indian systems, where servers usually run UTC while business rules are defined in IST at plus five thirty. A test that passes on a developer laptop set to Asia/Kolkata and fails on a UTC CI runner is almost always a missing ZoneId, so pass the zone explicitly rather than relying on the JVM default, and add at least one test that pins a boundary case such as a payout cutoff at 6 PM IST which falls on the previous UTC day.

class SessionTokenTest {

  private static final Instant NOW = Instant.parse("2026-08-11T13:00:00Z");
  private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);

  @Test
  void tokenIsValidUpToTheLastSecond() {
    SessionToken token = new TokenService(clock).issue("user-1", Duration.ofMinutes(30));

    assertTrue(token.isValidAt(NOW.plus(Duration.ofMinutes(29).plusSeconds(59))));
    assertFalse(token.isValidAt(NOW.plus(Duration.ofMinutes(30).plusSeconds(1))));
  }

  @Test
  void payoutCutoffIsEveningIst() {
    ZonedDateTime ist = NOW.atZone(ZoneId.of("Asia/Kolkata"));   // 18:30 IST
    assertTrue(new PayoutWindow(ZoneId.of("Asia/Kolkata")).isClosed(ist));
  }
}
💡 Pro Tip: Grep the codebase for Instant.now() outside configuration classes. Each hit is a test you cannot write deterministically.
Q37

How does ExtensionContext store scoping work, and how do you start an expensive resource exactly once for the whole suite?

AdvancedExtension Model

Answer

Every test method, test class and nested class has its own ExtensionContext, and those contexts form a tree whose root is the engine-level context created once per launch. getStore(Namespace) returns a store bound to the context you asked, and a lookup walks up the parent chain, so a value put in the root store is visible everywhere while a value put in a method-level store dies with that method. That hierarchy is the mechanism behind the singleton container pattern: an extension that needs one Postgres or one Kafka for the entire run puts the started instance into the root context store under a private Namespace, and every later class finds it already there instead of starting its own. Values stored this way are cleaned up when their context closes, and if the stored object implements ExtensionContext.Store.CloseableResource (AutoCloseable is also honoured in recent versions) JUnit calls close for you, which is how a root-scoped container is stopped after the last test rather than leaked.

The alternative one-per-JVM trick, a static field plus a JVM shutdown hook, works but does not compose across forks and gives you no ordering guarantees. Two details interviewers probe: extension instances are shared, so per-test state must live in the store and not in a field, and Namespace.create should include a class literal and usually the unique id so two extensions cannot collide on the same key. If you also need something before any engine runs, LauncherSessionListener is the hook that fires once per launcher session.

public class SharedPostgresExtension implements BeforeAllCallback {

  private static final Namespace NS = Namespace.create(SharedPostgresExtension.class);
  private static final String KEY = "postgres";

  @Override
  public void beforeAll(ExtensionContext ctx) {
    // getRoot() = one entry for the entire launch, shared by every test class
    ctx.getRoot().getStore(NS).getOrComputeIfAbsent(KEY, k -> new ContainerResource());
  }

  static class ContainerResource implements ExtensionContext.Store.CloseableResource {
    final PostgreSQLContainer<?> container = new PostgreSQLContainer<>("postgres:16-alpine");

    ContainerResource() {
      container.start();
      System.setProperty("test.jdbc.url", container.getJdbcUrl());
    }

    @Override public void close() { container.stop(); }   // called when the root context closes
  }
}

Key Points

  • Contexts form a tree, store lookups walk up to the root
  • Root store plus getOrComputeIfAbsent gives one resource per launch
  • CloseableResource values are closed when their context closes
  • Extension instances are shared, so per-test state belongs in the store
Q38

You switched on parallel execution and forty tests started failing intermittently. How do you diagnose and fix it?

AdvancedParallel Execution

Answer

Parallelism does not create these bugs, it reveals shared state that was always there. Work through the usual sources in order. Static mutable fields: a static cache, a static counter, a static SimpleDateFormat (not thread safe), a static ObjectMapper configured differently by different tests.

Global JVM state: System.setProperty, Locale.setDefault, TimeZone.setDefault, System.setOut, and security or HTTP proxy settings. Anything singleton in the application under test, including Spring beans that hold request state in fields. Database contention: two tests inserting the same primary key or truncating a table another test is reading, which produces deadlocks and lock timeouts rather than clean assertion failures.

Fixed ports and fixed filenames, so use dynamic ports and @TempDir. Mockito static mocks, which are thread scoped and simply cannot run concurrently on the same class. The fixes, in order of preference: remove the shared state, give each test its own data with a unique key derived from the test name or a UUID, then declare @ResourceLock with a named key so JUnit serialises only the contending tests instead of everything, and only as a last resort mark a class @Execution(SAME_THREAD) or @Isolated.

Reach for @Isolated sparingly because it stops the world while that class runs. Roll parallelism out in stages, classes concurrent first, then methods within safe packages, and keep a CI job that runs the suite sequentially so you can tell a genuine regression from a concurrency artefact.

// Before: shared static state, fails under concurrent execution
static final SimpleDateFormat FMT = new SimpleDateFormat("dd-MM-yyyy");  // not thread safe

// After: no shared state
private static final DateTimeFormatter FMT =
    DateTimeFormatter.ofPattern("dd-MM-yyyy");   // immutable and thread safe

// Tests that must touch the same resource: serialise only those
@ResourceLock(value = "payouts-table", mode = ResourceAccessMode.READ_WRITE)
@Test void truncatesAndReloadsPayouts() { }

@ResourceLock(value = "payouts-table", mode = ResourceAccessMode.READ)
@Test void readsPayoutSummary() { }      // READ locks run concurrently with each other

// Unique data instead of a shared row
String email = "user-" + UUID.randomUUID() + "@goodspace.ai";
💡 Pro Tip: Before adding @Isolated, ask what exactly is shared. Nine times out of ten the answer is a static field you can delete.
Q39

What is @TestTemplate, and when would you write a TestTemplateInvocationContextProvider?

AdvancedExtension Model

Answer

@TestTemplate marks a method that is not a test by itself but a template invoked multiple times, with each invocation configured by a TestTemplateInvocationContextProvider extension. @ParameterizedTest and @RepeatedTest are both implemented on top of it, which is the giveaway that this is the general mechanism and they are two special cases. You write your own provider when you need each invocation to carry its own display name, its own additional extensions, and its own resolved parameters, and crucially when each invocation must get the full lifecycle: @BeforeEach, @AfterEach and every registered extension run around every invocation, unlike dynamic tests from @TestFactory. Realistic uses: run the same contract test against three database dialects, run the same API test against v1, v2 and v3 of an endpoint, run the same scenario for each tenant configuration or each supported locale, or run a test once per feature-flag combination.

The provider implements supportsTestTemplate and provideTestTemplateInvocationContexts, returning a stream of contexts, each of which supplies a display name and a list of additional extensions, usually a ParameterResolver that injects that invocation's configuration object. The result is far cleaner than a for loop inside a single test, because each invocation reports as its own test, fails independently, and can be re-run individually from the IDE. Interviewers ask this to see whether you know that Jupiter's parameterized support is not special-cased in the engine but built from a public extension point you can reuse.

@TestTemplate
@ExtendWith(DialectInvocationProvider.class)
void pagingQueryWorks(Dialect dialect) {
  assertEquals(20, new PagingQuery(dialect).build().limit());
}

class DialectInvocationProvider implements TestTemplateInvocationContextProvider {

  @Override public boolean supportsTestTemplate(ExtensionContext ctx) { return true; }

  @Override
  public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext ctx) {
    return Stream.of(Dialect.POSTGRES, Dialect.MYSQL, Dialect.ORACLE).map(this::contextFor);
  }

  private TestTemplateInvocationContext contextFor(Dialect dialect) {
    return new TestTemplateInvocationContext() {
      @Override public String getDisplayName(int index) { return "dialect " + dialect; }

      @Override public List<Extension> getAdditionalExtensions() {
        return List.of(new TypedParameterResolver<>(Dialect.class, dialect));
      }
    };
  }
}
Q40

How would you implement retry-on-failure with an extension, and why is that usually the wrong fix?

AdvancedExtension Model

Answer

Two extension points can do it. TestExecutionExceptionHandler lets you observe or swallow an exception thrown by a test, which is enough to convert a failure into a pass but cannot re-run anything. InvocationInterceptor is the real tool: interceptTestMethod receives the Invocation and can call proceed() inside a loop, catching failures and retrying up to a limit, and it can also intercept lifecycle methods, constructors and template invocations.

A production-grade version combines an InvocationInterceptor with a TestTemplateInvocationContextProvider so each attempt appears as its own reported invocation instead of hiding the earlier failures. The reason this is usually the wrong fix is that a retry converts an unreliable test into an unreliable signal. Once a test can pass on attempt three, nobody investigates the two failures, and a genuine race condition in production code becomes invisible.

Worse, if the test leaves state behind on the failed attempt (a half-written row, an open connection, a filled cache), the retry may pass because of that state and not despite it. The defensible use is narrow: a test that genuinely depends on an external system with a documented failure rate, quarantined behind a tag, with the retry count and the failure recorded so the flake rate is visible on a dashboard. Maven Surefire's rerunFailingTestsCount does the same at build level and is easier to audit because the report distinguishes flaky from passed. The engineering answer interviewers want is: measure the flake, fix the root cause, use retries only to keep the pipeline moving while you do.

public class RetryOnFailure implements InvocationInterceptor {

  private final int maxAttempts = 3;

  @Override
  public void interceptTestMethod(Invocation<Void> invocation,
                                  ReflectiveInvocationContext<Method> ctx,
                                  ExtensionContext ext) throws Throwable {
    Throwable last = null;
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        invocation.proceed();
        return;
      } catch (Throwable t) {
        last = t;
        // proceed() can only be called once per Invocation, so a real
        // implementation re-invokes the method reflectively here and
        // publishes each attempt through the TestReporter.
        break;
      }
    }
    throw last;
  }
}

<!-- build-level alternative, reported as flaky rather than passed -->
<!-- mvn test -Dsurefire.rerunFailingTestsCount=2 -->

Key Points

  • InvocationInterceptor can wrap test methods, lifecycle methods and constructors
  • TestExecutionExceptionHandler can swallow a failure but cannot re-run it
  • Retries hide races and can pass because of state left by a failed attempt
  • Surefire rerunFailingTestsCount reports flaky separately, which keeps the signal
Q41

How do you run JUnit tests programmatically with the Launcher API, and what is @Suite for?

AdvancedPlatform API

Answer

junit-platform-launcher exposes the same entry point IDEs and build tools use. You build a LauncherDiscoveryRequest with LauncherDiscoveryRequestBuilder, adding selectors (selectPackage, selectClass, selectMethod, selectClasspathRoots, selectUniqueId) and filters (includeClassNamePatterns, includeTags, excludeTags), then call LauncherFactory.create() and either discover() to inspect the test plan without running anything, or execute() with listeners attached. SummaryGeneratingListener gives you counts and failures; implementing TestExecutionListener yourself lets you stream results into a custom dashboard, publish timings, or fail a build on a duration budget.

This is how you build an internal test-selection tool, for example running only the tests whose package matches the modules touched by a pull request, which is a serious win once a suite crosses ten minutes. The ConsoleLauncher (junit-platform-console-standalone) is the same machinery as a runnable jar, useful in Docker images and shell scripts where you do not want Maven on the path. @Suite from junit-platform-suite-api is the declarative version: annotate a class with @Suite plus @SelectPackages, @SelectClasses, @IncludeTags or @IncludeClassNamePatterns, add junit-platform-suite-engine at runtime, and the suite becomes a test container the build tool discovers like any other. It replaced JUnit 4's @RunWith(Suite.class), and it is the right way to define a named smoke or regression set that both CI and developers can run by name.

// Declarative suite
@Suite
@SuiteDisplayName("Payments smoke suite")
@SelectPackages("ai.goodspace.payments")
@IncludeTags("smoke")
@ExcludeClassNamePatterns(".*LegacyTest")
class PaymentsSmokeSuite { }

// Programmatic execution
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
    .selectors(selectPackage("ai.goodspace.payments"), selectClass(PayoutServiceTest.class))
    .filters(includeTags("fast"), includeClassNamePatterns(".*Test"))
    .build();

Launcher launcher = LauncherFactory.create();
SummaryGeneratingListener summary = new SummaryGeneratingListener();
launcher.execute(request, summary);

TestExecutionSummary result = summary.getSummary();
System.out.println(result.getTestsSucceededCount() + " passed, "
    + result.getTestsFailedCount() + " failed");
Q42

How do you wire JaCoCo with Surefire correctly, and why is 85% line coverage a weak quality signal?

AdvancedCoverage

Answer

JaCoCo instruments through a Java agent that its prepare-agent goal puts into a property, by convention argLine. The single most common misconfiguration in Java builds is hardcoding argLine in the Surefire configuration, which overwrites the agent property, so the agent never attaches and the coverage report shows zero or is missing entirely while the build stays green. The fix is to reference the property with late evaluation, @{argLine}, or to bind prepare-agent to a custom property name and include it explicitly.

On multi-module builds add report-aggregate in a dedicated module, and remember Failsafe needs its own prepare-agent-integration and report-integration executions or your integration tests contribute nothing to the numbers. On the metric itself: line coverage counts lines executed, not behaviour verified. A test that calls a method and asserts nothing produces the same line coverage as one that asserts every branch, and getters, generated builders and Lombok code inflate the percentage without meaning anything.

Branch coverage is better because it forces both sides of every condition. The honest measure is mutation testing with PIT (pitest-junit5-plugin for Jupiter): it mutates the bytecode, negating conditionals, changing return values, removing calls, and reports the mutation score, which is the percentage of mutants your tests actually killed. Teams that look at both usually discover a module sitting at 90% line coverage and 40% mutation score, which is exactly the class of code that ships bugs. Use coverage as a floor against untested modules, never as evidence of quality.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution><id>prepare</id><goals><goal>prepare-agent</goal></goals></execution>
    <execution><id>report</id><phase>test</phase><goals><goal>report</goal></goals></execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules><rule>
          <limits><limit>
            <counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.70</minimum>
          </limit></limits>
        </rule></rules>
      </configuration>
    </execution>
  </executions>
</plugin>

<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <!-- @{argLine} keeps the JaCoCo agent; plain ${argLine} or a literal drops it -->
    <argLine>@{argLine} -Xmx1g -Duser.timezone=UTC</argLine>
  </configuration>
</plugin>

Key Points

  • Overwriting argLine in Surefire silently disables the JaCoCo agent
  • Use @{argLine} for late property evaluation
  • Failsafe needs prepare-agent-integration for integration coverage
  • Branch coverage beats line coverage, mutation score with PIT beats both
Q43

A suite of 4,000 tests is flaky enough to block merges. Walk through how you would stabilise it.

AdvancedCI and Flakiness

Answer

Measure before you fix. Turn on Surefire's rerunFailingTestsCount so the report distinguishes flaky from failed, and persist the XML reports so you can rank tests by flake rate over a fortnight. In almost every codebase a small handful of tests cause most of the noise, and fixing eight tests recovers the pipeline.

Quarantine those immediately behind a @Tag("flaky") excluded from the merge gate but still executed in a nightly job, each with a ticket and an owner, so the pipeline unblocks the same day while the debt stays visible. Then classify by cause. Order dependence: run the suite with MethodOrderer.Random and ClassOrderer.Random and pin the seed to reproduce.

Shared state under parallel execution: the static fields, global JVM settings and fixed ports described earlier. Time: Thread.sleep budgets that lose to a loaded runner, and clock boundaries such as a test that only fails between 23:30 and 00:00 IST because it crosses a UTC date. Resource exhaustion: Surefire forkCount and reuseForks interact with heap, and a test that passes alone but fails in a full run is often an OutOfMemoryError or a leaked connection pool.

External systems: any real network call in a unit test is a flake generator, replace with WireMock or a container. Finally, prevention: a merge gate that fails on new sleeps, an ArchUnit rule banning network access in unit tests, and a weekly randomised-order run so coupling is caught the week it is introduced rather than the quarter.

<!-- surefire: report flaky separately, control forking and memory -->
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <rerunFailingTestsCount>2</rerunFailingTestsCount>
    <forkCount>1C</forkCount>          <!-- one JVM per CPU core -->
    <reuseForks>true</reuseForks>
    <argLine>@{argLine} -Xmx2g -Duser.timezone=UTC -Duser.language=en</argLine>
    <excludedGroups>flaky</excludedGroups>
  </configuration>
</plugin>

# nightly job: run the quarantine and shuffle everything
# mvn test -Dgroups=flaky
# mvn test -Djunit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
💡 Pro Tip: Pin user.timezone and user.language in the build. Half of all locale and date flakes disappear the day you do.
Q44

How do you write a JUnit test that meaningfully exercises concurrent code?

AdvancedConcurrency Testing

Answer

Start by accepting that a plain unit test cannot prove thread safety, it can only raise the probability of catching a violation. The practical pattern is: create a fixed thread pool of more threads than cores, use a CountDownLatch as a starting gate so all threads hit the code at the same instant rather than staggered, submit N operations, use a second latch or invokeAll to wait for completion, then assert the aggregate invariant, for example that a counter equals the number of increments or that a cache never returned a partially constructed object. Always shut the executor down in a finally block or with try-with-resources on Java 21 and later, since ExecutorService became AutoCloseable.

Collect exceptions from the futures explicitly, because an exception inside a submitted task is swallowed unless you call get(). Repeat the whole thing a few dozen times, since a race may surface in one run out of thirty. Two traps to name in an interview. assertTimeoutPreemptively runs the block on another thread, which breaks anything using ThreadLocal, including Spring's transaction synchronisation and MDC-based logging, so a test can fail or pass for reasons unrelated to the code.

And Thread.sleep as synchronisation makes the test both slow and unreliable; use latches, CompletableFuture or Awaitility. For genuinely subtle memory-model questions (visibility, reordering, missing volatile), an ordinary JUnit test is the wrong instrument and jcstress is the right one, because it exercises the actual JMM interleavings that a strong x86 CPU would otherwise hide until the code runs on ARM.

@RepeatedTest(20)
void counterIsAtomicUnderContention() throws Exception {
  int threads = 32, perThread = 1_000;
  CountDownLatch startGate = new CountDownLatch(1);
  CountDownLatch done = new CountDownLatch(threads);
  HitCounter counter = new HitCounter();
  List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());

  ExecutorService pool = Executors.newFixedThreadPool(threads);
  try {
    for (int i = 0; i < threads; i++) {
      pool.submit(() -> {
        try {
          startGate.await();                       // release all threads together
          for (int j = 0; j < perThread; j++) counter.hit("jobs");
        } catch (Throwable t) { errors.add(t); } finally { done.countDown(); }
      });
    }
    startGate.countDown();
    assertTrue(done.await(10, TimeUnit.SECONDS), "threads did not finish");
  } finally {
    pool.shutdownNow();
  }

  assertTrue(errors.isEmpty(), () -> "task errors: " + errors);
  assertEquals(threads * perThread, counter.count("jobs"));
}
Q45

How would you migrate a 5,000-test JUnit 4 codebase to JUnit 5 without freezing feature work?

AdvancedMigration

Answer

Run both engines side by side. Add the junit-bom and junit-jupiter, keep junit-vintage-engine and the JUnit 4 dependency, and confirm the total test count before and after is identical, because a drop means some tests silently stopped being discovered. From that point every new test is written in Jupiter and old tests migrate opportunistically whenever their class is touched, so nothing is frozen.

Automate the mechanical part with OpenRewrite's JUnit 5 migration recipe or IntelliJ's built-in conversion; both handle imports, annotation renames, expected and timeout attributes, and the assertion argument reordering. What automation cannot do is the Rules. Each @Rule becomes an extension: TemporaryFolder maps to @TempDir, ExpectedException maps to assertThrows, TestName maps to TestInfo, and custom rules become BeforeEachCallback plus AfterEachCallback classes, which is usually a clean improvement because extensions compose while rules stacked awkwardly.

Parameterized tests written with @RunWith(Parameterized.class) need rewriting to @ParameterizedTest with @MethodSource, and that is where most of the manual effort sits. Watch for classes half converted, since a JUnit 4 import with a Jupiter lifecycle annotation compiles and runs under Vintage with the setup silently skipped; add a Checkstyle or ArchUnit rule banning org.junit imports in newly touched packages, and turn it on globally the day the last file moves. Finish by deleting the Vintage engine and the JUnit 4 dependency, which is the only way the migration truly ends. If your team is on the newer JUnit 6 line rather than 5.x, the Jupiter programming model is the same and the headline change is a raised minimum Java version, so plan the JDK upgrade first and treat the JUnit bump as the easy half.

// Rule to extension mapping seen most often
// @Rule TemporaryFolder folder     -> @TempDir Path folder
// @Rule ExpectedException thrown   -> assertThrows(...)
// @Rule TestName name              -> TestInfo info parameter
// @RunWith(MockitoJUnitRunner)     -> @ExtendWith(MockitoExtension.class)
// @RunWith(SpringRunner.class)     -> @ExtendWith(SpringExtension.class) (implied by @SpringBootTest)

// JUnit 4 parameterized
@RunWith(Parameterized.class)
public class SlabTest {
  @Parameterized.Parameters
  public static Collection<Object[]> data() { return List.of(new Object[]{5, 1050}); }
}

// Jupiter equivalent
class SlabTest {
  @ParameterizedTest
  @CsvSource({"5, 1050", "12, 1120", "18, 1180"})
  void appliesSlab(int percent, int expected) {
    assertEquals(expected, calculator.withGst(1000, percent));
  }
}

Key Points

  • Keep Vintage so both engines run and no work is frozen
  • Verify total test count is unchanged after adding the engines
  • OpenRewrite or the IDE handles imports and annotation renames
  • Rules and @RunWith(Parameterized.class) need real human rewriting
  • Delete Vintage and the JUnit 4 dependency to actually finish

Companies Hiring JUnit

TCS
Infosys
Accenture
Cognizant
Flipkart
PhonePe
Zoho
Thoughtworks

Salary Insights

Average in India
₹5-18 LPA

Frequently Asked Questions

What salary can a Java developer with strong JUnit skills expect in India?

JUnit on its own is not a job title, it is a component of a Java or SDET role, and the band is roughly ₹5-18 LPA in 2026. Freshers in service companies such as TCS, Infosys and Cognizant start around ₹3.5-6 LPA with unit testing treated as an expected baseline skill. Mid-level Java backend engineers with three to six years land ₹10-22 LPA at product companies. The premium goes to people who own the test infrastructure rather than just write tests: engineers who can cut a 25-minute pipeline to 6 minutes with parallel execution and Testcontainers, or who introduce mutation testing, are visibly valuable and negotiate accordingly. Dedicated SDET roles at product companies in Bengaluru, Pune and Hyderabad typically run ₹12-28 LPA.

How long does it take to prepare for JUnit questions in a Java interview?

If you already write Java daily, two focused weekends is enough for the interview surface: lifecycle and callback order, the assertion family, parameterized argument sources, Mockito with strict stubs, and the Surefire versus Failsafe split. Getting genuinely good takes a project. Pick a service you own, add Testcontainers for the database layer, turn on parallel execution and fix everything it breaks, then wire JaCoCo with branch thresholds and run PIT once. That sequence takes about three weeks part time and gives you concrete stories about specific failures, which is what senior interviewers actually score. Reading about parallel execution teaches you the config keys, breaking a suite with it teaches you the answer.

What is expected from a fresher versus someone with five years of experience?

A fresher should write a clean test unprompted: arrange, act, assert, one behaviour per test, a meaningful name, no logic in the test body. They should know @BeforeEach versus @BeforeAll, assertThrows, why the new instance per method matters, and basic Mockito stubbing and verification. At five years the questions change completely. You will be asked why the CI suite takes 22 minutes and what you would do about it, how Spring context caching works, how you would quarantine a flaky test without hiding it, whether 85% coverage means anything, and how you would test a class that calls Instant.now() three times. Interviewers at that level are checking whether you have owned a test suite, not whether you can recite annotations.

Is JUnit still worth learning in 2026 when AI tools generate tests?

Yes, and the reason has shifted. Assistants generate test scaffolding quickly, so typing @ParameterizedTest is no longer the valuable part. Judging output is. Generated tests routinely assert on implementation detail, over-mock until they verify nothing real, stub collaborators that strict stubbing then flags as unnecessary, and produce high line coverage with a low mutation score. Reviewing that requires exactly the knowledge in this guide. Interviewers have adapted too: many now hand over a generated test class and ask what is wrong with it, which is a much better signal than asking for annotation definitions. Reading tests critically is now the more valuable half of the skill.

Should I learn JUnit or TestNG for the Indian job market?

JUnit 5 first, without much hesitation. It is the default in Spring Boot, in every Maven and Gradle archetype, and in most product codebases, and the JUnit Platform is what other frameworks plug into. TestNG remains common in older Selenium and QA automation stacks, particularly in service-company projects, and it retains genuine advantages for suite-level orchestration through testng.xml, dependsOnMethods and built-in data providers with grouping. The practical path is to know Jupiter deeply and be able to read TestNG, since the concepts transfer in an afternoon. If a specific job description names TestNG, learn its XML suite model and dependency features, because those are what interviewers actually probe.

Do QA and SDET roles need JUnit, or is Selenium enough?

Selenium and Playwright drive the browser, but something has to structure, parameterize, tag, parallelise and report those tests, and in a Java stack that is JUnit or TestNG. SDET interviews in India lean heavily on exactly the topics here: parallel execution and thread safety of the WebDriver instance, running one scenario across browsers with @TestTemplate or parameterized sources, wiring Testcontainers or a Selenium Grid, and keeping a UI suite from becoming permanently flaky. Candidates who can only write a page object and a click sequence stall at the mid level. Adding JUnit depth plus API-level testing with RestAssured or WebTestClient is what moves an automation engineer into the higher band.

Introduction

JUnit is the harness the entire Java build ecosystem is wired around. Maven Surefire, Gradle, IntelliJ, Eclipse and every CI runner speak the JUnit Platform protocol, so whatever else a Java team adopts (Mockito, AssertJ, Testcontainers, Spring Boot Test, Cucumber) it ultimately plugs into JUnit. JUnit 5 split the project into three parts: the Platform that discovers and launches test engines, Jupiter that supplies the annotations and assertions you write against, and Vintage that keeps existing JUnit 3 and 4 tests running on the same launcher. That split is exactly why Jupiter could introduce nested classes, parameterized tests, dynamic tests and a real extension model without breaking a decade of legacy suites.

In Indian interviews JUnit is rarely the headline skill on the job description, yet it quietly decides a lot of Java offers. Service firms such as TCS, Infosys, Cognizant and Accenture ask lifecycle, annotation and mocking basics because client contracts carry coverage gates. Product teams at Flipkart, PhonePe, Zoho and Thoughtworks push much deeper: parallel execution and shared state, Testcontainers under Failsafe, Spring context caching, strict stubbing, mutation testing, and how you would stop a flaky suite from blocking twenty pull requests on a Monday morning. Expect at least one live round where you write tests for a class handed to you, followed by the question that separates candidates: would this test still pass if the implementation were broken?

This guide covers 45 JUnit questions ordered from basic to advanced, using the exact API names, configuration keys and error messages that show up in real interviews. The first eighteen consolidate Jupiter fundamentals: callback order, assertion semantics, Surefire and Gradle wiring. The next eighteen move into parameterized argument sources, Mockito integration, Spring test slices and Testcontainers. The final nine cover what actually decides senior offers: the extension context store, parallel-execution safety, the Launcher API, coverage that measures something real, and migrating a large JUnit 4 suite without freezing feature work for a quarter.

Ready to practice JUnit interviews?

Don't just read, practice these JUnit questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview