Testing / JUnit6 Interview Questions
1. What is JUnit 6 and when was it released?
JUnit 6 is the current major version of the JUnit testing framework for Java and the JVM, released as GA on September 30, 2025 -- eight years after JUnit 5. The latest stable release is 6.1.1 (mid-2026). Unlike the disruptive JUnit 4 to 5 migration (which rewrote the entire annotation model), JUnit 6 is a deliberate maturation rather than a revolution.
JUnit 6 is built on the same Jupiter architecture introduced in JUnit 5 and adds:
- Java 17 minimum baseline (up from Java 8 in JUnit 5)
- Unified versioning across Platform, Jupiter, and Vintage modules
- JSpecify nullability annotations across the entire public API
- Native Kotlin coroutine support (
suspendtest methods) - CancellationToken API for cooperative cancellation and fail-fast execution
- FastCSV replacing the unmaintained univocity-parsers library
- Removal of long-deprecated modules (
junit-platform-runner,junit-platform-jfr)
| Property | Detail |
|---|---|
| GA release date | September 30, 2025 |
| Latest stable | 6.1.1 (mid-2026) |
| Minimum Java | Java 17 |
| Minimum Kotlin | Kotlin 2.2 |
| Architecture | Jupiter engine (same as JUnit 5) |
| Migration from JUnit 5 | Mostly a version bump; same annotation model |
2. What is the JUnit 6 architecture and what are its three main components?
JUnit 6 retains the three-tier architecture introduced in JUnit 5, with one key change: all three components now share a single unified version number instead of having separate version schemes.
| Component | Artifact prefix | Role |
|---|---|---|
| JUnit Platform | junit-platform-* | Foundation: defines TestEngine SPI, launcher API, and the mechanism for discovering and executing tests. IDEs and build tools integrate at this layer. |
| JUnit Jupiter | junit-jupiter-* | Writing tests: provides all annotations (@Test, @BeforeEach, @ParameterizedTest, etc.), assertions, and extension APIs used by developers writing tests. |
| JUnit Vintage | junit-vintage-* | Legacy: runs JUnit 3 and JUnit 4 tests on the JUnit Platform. Deprecated in JUnit 6 -- will be removed in a future major version. |
<!-- Maven BOM: single version for all modules in JUnit 6 --> <dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>6.1.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> <!-- Version resolved by BOM: 6.1.1 --> </dependency> </dependencies> <!-- JUnit 5 used different version numbers: Platform: 1.x.x Jupiter: 5.x.x Vintage: 5.x.x JUnit 6: all three use 6.x.x -->
Key change from JUnit 5: in JUnit 5, the Platform used version 1.x.x while Jupiter and Vintage used 5.x.x. This caused confusion in dependency management. JUnit 6 unifies all three under the same 6.x.x version, making BOM usage straightforward and eliminating version mismatch errors.
3. What are the core annotations in JUnit 6 and what does each do?
The JUnit 6 annotation model is unchanged from JUnit 5 Jupiter -- this is by design, making migration from JUnit 5 to JUnit 6 primarily a version bump rather than an annotation rewrite.
| Annotation | Purpose | Notes |
|---|---|---|
| @Test | Marks a method as a test case | No parameters; replaces JUnit 4's @Test |
| @BeforeEach | Runs before each test method | Replaces JUnit 4's @Before |
| @AfterEach | Runs after each test method | Replaces JUnit 4's @After |
| @BeforeAll | Runs once before all tests in the class | Must be static unless @TestInstance(PER_CLASS) |
| @AfterAll | Runs once after all tests in the class | Must be static unless @TestInstance(PER_CLASS) |
| @Disabled | Skips the annotated test or class | Replaces JUnit 4's @Ignore |
| @DisplayName | Custom display name for the test | Supports Unicode, emojis, spaces |
| @Nested | Marks an inner class as a nested test class | Enables hierarchical test organisation |
| @Tag | Categorises tests for filtering | Replaces JUnit 4's @Category |
import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; @DisplayName("Order service tests") class OrderServiceTest { OrderService service; @BeforeAll static void initAll() { System.out.println("Runs once before all tests"); } @BeforeEach void setUp() { service = new OrderService(); } @Test @DisplayName("New order has PENDING status") void newOrderIsPending() { Order order = service.create("item-1", 2); assertEquals(Status.PENDING, order.getStatus()); } @Test @Disabled("Not implemented yet") void cancelledOrderIsRefunded() { // TODO } @AfterEach void tearDown() { service = null; } @AfterAll static void tearDownAll() { System.out.println("Runs once after all tests"); } }
4. What assertions does JUnit 6 provide and how do you use them?
JUnit 6 provides a rich set of assertions in org.junit.jupiter.api.Assertions. All methods are static and can be statically imported. Assertions fail the test immediately when the condition is not met (unlike assumptions, which abort silently).
import static org.junit.jupiter.api.Assertions.*; class AssertionsDemo { @Test void basicAssertions() { assertEquals(4, 2 + 2); assertNotEquals(5, 2 + 2); assertTrue(4 > 3); assertFalse(4 < 3); assertNull(null); assertNotNull("hello"); } @Test void assertionsWithMessages() { // Message is lazily evaluated (supplier) -- no cost if test passes assertEquals(4, 2 + 2, () -> "Expected 4 but was " + (2 + 2)); } @Test void groupedAssertions() { // assertAll: ALL assertions run even if some fail // Reports ALL failures, not just the first one assertAll("address", () -> assertEquals("London", address.city()), () -> assertEquals("UK", address.country()), () -> assertEquals("EC1A 1BB", address.postcode()) ); } @Test void exceptionAssertions() { // assertThrows: passes only if the lambda throws the expected type Exception ex = assertThrows(IllegalArgumentException.class, () -> new Order(null, -1)); assertEquals("Quantity must be positive", ex.getMessage()); } @Test void doesNotThrow() { assertDoesNotThrow(() -> new Order("item", 1)); } @Test void timeoutAssertions() { // Fails if the lambda takes longer than 1 second assertTimeout(Duration.ofSeconds(1), () -> Thread.sleep(500)); } }
Key difference -- assertAll vs chained assertions: chained assertions stop at the first failure. assertAll runs every assertion and reports all failures in one go, making it far more useful for validating complex objects with multiple fields.
5. What are parameterized tests in JUnit 6 and how do you write them?
Parameterized tests run the same test method multiple times with different arguments. In JUnit 6 they are written with @ParameterizedTest and a source annotation that provides the data.
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.*; class ParameterizedDemo { // @ValueSource: simple single-argument tests @ParameterizedTest @ValueSource(ints = {1, 2, 3, 4, 5}) void isPositive(int n) { assertTrue(n > 0); } // @CsvSource: multiple arguments per invocation @ParameterizedTest(name = "{index}: add({0}, {1}) = {2}") @CsvSource({ "1, 2, 3", "10, 20, 30", "-5, 5, 0" }) void additionTest(int a, int b, int expected) { assertEquals(expected, a + b); } // @MethodSource: call a static method returning a Stream @ParameterizedTest @MethodSource("provideStrings") void blankStringsAreRejected(String input) { assertThrows(IllegalArgumentException.class, () -> new Username(input)); } static Stream<String> provideStrings() { return Stream.of("", " ", " ", null); } // @EnumSource: test all or selected enum values @ParameterizedTest @EnumSource(value = Status.class, names = {"PENDING", "PROCESSING"}) void activeStatusesAreNotFinal(Status s) { assertFalse(s.isFinal()); } // @NullSource / @EmptySource / @NullAndEmptySource @ParameterizedTest @NullAndEmptySource void nullAndEmptyAreRejected(String input) { assertThrows(IllegalArgumentException.class, () -> new Username(input)); } }
JUnit 6 display name change: parameterized test names now consistently format arguments as name = value (with spaces around =) instead of JUnit 5's name=value. This affects CI report output and test filtering by name.
6. What is the CancellationToken API introduced in JUnit 6?
One of the headline new features in JUnit 6 is the CancellationToken API -- a cooperative cancellation mechanism that allows launchers (Gradle, Maven, IntelliJ, GitHub Actions) to signal a running test suite to stop cleanly, without killing the JVM process.
The problem it solves: in JUnit 5, there was no clean way to abort a long-running test suite mid-run. Build tools had to forcibly kill the JVM, which skipped teardown methods, left database connections open, and could leave the CI environment in an inconsistent state.
// JUnit 6: CancellationToken -- test and extension authors can check // for cancellation between steps and abort cleanly. // Extensions can check cancellation: class CleanupExtension implements BeforeEachCallback { @Override public void beforeEach(ExtensionContext context) throws Exception { // Check if the test run has been cancelled before starting setup if (context.getCancellationToken().isCancellationRequested()) { return; // skip setup gracefully } // ... normal setup ... } } // --fail-fast mode in ConsoleLauncher: // The first test failure triggers a CancellationToken that propagates // through the test suite, stopping remaining tests cleanly. java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --fail-fast // Equivalent in Maven Surefire: // <configuration> // <runOrder>random</runOrder> // <forkCount>1</forkCount> // </configuration> // (use --fail-at-end or failIfNoTests per tool docs)
| Scenario | JUnit 5 | JUnit 6 with CancellationToken |
|---|---|---|
| CI cancels long-running build | JVM killed; teardown skipped; resources leaked | Cooperative signal; @AfterEach and @AfterAll run; connections closed |
| --fail-fast mode | Not available | First failure signals cancellation; remaining tests skipped cleanly |
| Parallel test suite cancelled | Threads killed; database in inconsistent state | Each thread checks token between steps; graceful exit |
7. What are JSpecify nullability annotations in JUnit 6 and why do they matter?
JUnit 6 adds JSpecify nullability annotations (@Nullable, @NonNull, @NullMarked) to its entire public API. This is a significant improvement for static analysis, IDE tooling, and Kotlin interoperability.
| Annotation | Meaning | Where applied |
|---|---|---|
| @NonNull | The annotated element is never null | Method parameters and return types that must not be null |
| @Nullable | The annotated element may be null | Parameters or return types that can legitimately be null |
| @NullMarked | All unannotated types in this scope are treated as non-null by default | Applied at package, class, or module level to reduce annotation clutter |
// JUnit 6 API example with JSpecify annotations: // (simplified from actual JUnit 6 source) @NullMarked // all unannotated types are non-null by default public class Assertions { // @Nullable return type: assertNull can only be called with a // value the compiler considers potentially null public static void assertNull(@Nullable Object actual) { ... } // Non-null enforced: message MUST be non-null public static void assertEquals( Object expected, Object actual, String message // @NonNull from @NullMarked ) { ... } } // Kotlin benefit: Kotlin compiler recognises JSpecify annotations // Smart casts work after assertNotNull(): val result: String? = getValue() assertNotNull(result) // Kotlin sees NonNull contract println(result.length) // No !! needed -- compiler knows non-null // JUnit 5 problem: assertNotNull(result) // JUnit 5 had no contract println(result!!.length) // !! still needed -- compiler didnt know
8. What is the native Kotlin coroutine support in JUnit 6?
JUnit 6 adds first-class support for Kotlin suspend functions as test and lifecycle methods. In JUnit 5, testing coroutines required wrapping every test in runBlocking { }. JUnit 6 eliminates this boilerplate: the Jupiter engine manages the coroutine context internally.
// JUnit 5 (before): runBlocking required class NetworkServiceTest { @Test fun `should fetch user`(): Unit = runBlocking { val user = service.fetchUser("user-1") assertEquals("Alice", user.name) } @BeforeEach fun setUp(): Unit = runBlocking { service = NetworkService() service.connect() // suspend function } } // JUnit 6 (now): suspend directly supported class NetworkServiceTest { @Test suspend fun `should fetch user`() { // No runBlocking needed! val user = service.fetchUser("user-1") assertEquals("Alice", user.name) } @BeforeEach suspend fun setUp() { // Lifecycle methods can also be suspend service = NetworkService() service.connect() // suspend function -- no wrapping needed } @AfterEach suspend fun tearDown() { service.disconnect() } } // IMPORTANT NOTE: JUnit 6's suspend support is equivalent to // runBlocking -- it does NOT replace kotlin-coroutines-test // for testing concurrent behaviour, StateFlow, etc. // For full coroutine test control, use kotlinx-coroutines-test's // runTest in addition to JUnit 6.
9. What are the Extension Model and extension points in JUnit 6?
JUnit 6 uses a single, composable Extension Model replacing JUnit 4's fragmented @RunWith, @Rule, and @ClassRule system. Extensions implement one or more callback interfaces corresponding to specific points in the test lifecycle.
| Interface | Callback method | When it executes |
|---|---|---|
| BeforeAllCallback | beforeAll(ExtensionContext) | Before all tests in the class |
| BeforeEachCallback | beforeEach(ExtensionContext) | Before each test method |
| BeforeTestExecutionCallback | beforeTestExecution(ExtensionContext) | Just before the test method body |
| AfterTestExecutionCallback | afterTestExecution(ExtensionContext) | Just after the test method body |
| AfterEachCallback | afterEach(ExtensionContext) | After each test method (including cleanup) |
| AfterAllCallback | afterAll(ExtensionContext) | After all tests in the class |
| TestInstancePostProcessor | postProcessTestInstance(...) | After test instance creation |
| ParameterResolver | supportsParameter + resolveParameter | Inject custom parameters into test methods |
// Custom extension: time each test and log slow ones @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @ExtendWith(TimingExtension.class) public @interface Timed { long thresholdMs() default 100; } public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { private static final String START = "start_time"; @Override public void beforeTestExecution(ExtensionContext ctx) { ctx.getStore(GLOBAL).put(START, System.currentTimeMillis()); } @Override public void afterTestExecution(ExtensionContext ctx) { long start = ctx.getStore(GLOBAL).remove(START, long.class); long elapsed = System.currentTimeMillis() - start; Timed ann = ctx.getRequiredTestMethod().getAnnotation(Timed.class); if (ann != null && elapsed > ann.thresholdMs()) { System.out.printf("[SLOW] %s took %dms%n", ctx.getDisplayName(), elapsed); } } } // Usage: @Test @Timed(thresholdMs = 200) void slowIntegrationTest() { ... }
10. What are nested tests in JUnit 6 and what is the @TestClassOrder/@TestMethodOrder inheritance change?
Nested tests (inner classes annotated with @Nested) allow you to organise tests hierarchically, grouping related tests and sharing setup/teardown context. JUnit 6 introduces a notable change: @TestClassOrder and @TestMethodOrder are now recursively inherited by all @Nested classes, giving deterministic ordering throughout the hierarchy.
import org.junit.jupiter.api.*; @DisplayName("Shopping cart") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ShoppingCartTest { Cart cart; @BeforeEach void init() { cart = new Cart(); } @Test @Order(1) @DisplayName("is empty on creation") void isEmpty() { assertTrue(cart.isEmpty()); } // @TestMethodOrder(MethodOrderer.OrderAnnotation.class) inherited // by @Nested classes in JUnit 6 -- no need to repeat the annotation @Nested @DisplayName("when items are added") class WhenItemsAdded { @BeforeEach void addItems() { cart.add(new Item("apple", 1.00)); } @Test @Order(1) @DisplayName("is not empty") void isNotEmpty() { assertFalse(cart.isEmpty()); } @Test @Order(2) @DisplayName("total reflects added items") void totalIsCorrect() { assertEquals(1.00, cart.total()); } @Nested @DisplayName("when item is removed") class WhenItemRemoved { @BeforeEach void remove() { cart.remove("apple"); } @Test @Order(1) @DisplayName("is empty again") void isEmptyAgain() { assertTrue(cart.isEmpty()); } } } }
11. What is @ParameterizedClass in JUnit 6 and how does it differ from @ParameterizedTest?
@ParameterizedClass is a JUnit 6 (and late JUnit 5) annotation that parameterises an entire test class rather than a single test method. Instead of repeating @ParameterizedTest on every method, the whole class is instantiated multiple times with different argument sets.
// @ParameterizedTest: repeats one method class OrderTest { @ParameterizedTest @CsvSource({"PENDING,false", "SHIPPED,true", "CANCELLED,true"}) void isFinalStatus(Status status, boolean expected) { assertEquals(expected, status.isFinal()); } // Problem: if 10 methods need the same data source, you repeat // the annotation 10 times } // @ParameterizedClass: repeats the WHOLE class (JUnit 6) @ParameterizedClass @CsvSource({"PENDING,false", "SHIPPED,true", "CANCELLED,true"}) class StatusTest { @Parameter(0) Status status; // field injection @Parameter(1) boolean expectedFinal; @Test void isFinalStatus() { assertEquals(expectedFinal, status.isFinal()); } @Test void descriptionIsNotEmpty() { assertFalse(status.description().isBlank()); } @Test void statusToStringContainsName() { assertTrue(status.toString().contains(status.name())); } // All 3 tests run for each of the 3 CSV rows: // 9 total test executions instead of 3x@ParameterizedTest } // Display name format in JUnit 6: "status = PENDING, expectedFinal = false" // JUnit 5 style was: "status=PENDING, expectedFinal=false" (no spaces)
12. What is the FastCSV migration in JUnit 6 and how does it affect @CsvSource and @CsvFileSource?
JUnit 6 replaced the univocity-parsers library (which had stopped receiving maintenance) with FastCSV for parsing CSV data in @CsvSource and @CsvFileSource. FastCSV is actively maintained, has better performance, and is stricter about malformed input.
| Aspect | JUnit 5 (univocity-parsers) | JUnit 6 (FastCSV) |
|---|---|---|
| Maintenance | Unmaintained library | Actively maintained |
| Strict parsing | Silently accepted some malformed CSV | Strict: throws exception on malformed input |
| Extra chars after quotes | Allowed silently: 'foo'INVALID | Exception thrown - no extra chars after closing quote |
| lineSeparator attribute | Present in @CsvFileSource | Removed: line separator auto-detected (\r, \n, \r\n) |
| Header fields | ignoreLeadingAndTrailingWhitespace did not apply to headers | Now applies to headers too |
| commentCharacter | Not configurable (# caused conflicts) | New commentCharacter attribute added; default still # |
// JUnit 5: this was silently accepted (malformed CSV) @CsvSource({"'foo'INVALID,'bar'"}) // JUnit 6: throws exception -- "extra characters after closing quote" // Fix: remove the extra characters @CsvSource({"'foo','bar'"}) // JUnit 5: @CsvFileSource with explicit line separator @CsvFileSource(resources = "/data.csv", lineSeparator = "\n") // JUnit 6: lineSeparator removed -- auto-detected @CsvFileSource(resources = "/data.csv") // Auto-detection handles \r, \n, and \r\n correctly // New in JUnit 6: configure commentCharacter // Previously # was hardcoded and conflicted with some delimiters @CsvSource( value = {"1, 2, 3", "# this is a comment"}, commentCharacter = '#' // can now be customised or disabled ) void additionTest(int a, int b, int expected) { ... }
13. What modules were removed in JUnit 6 and what replaced them?
JUnit 6 removed several modules that had been deprecated across JUnit 5.x releases. Understanding what was removed and what replaced it is important for migration.
| Removed module | What it did | Replacement in JUnit 6 |
|---|---|---|
| junit-platform-runner | JUnit 4 @RunWith(JUnitPlatform.class) runner for running JUnit Platform tests via JUnit 4 infrastructure | Use @Suite and @SelectClasses from junit-platform-suite directly |
| junit-platform-jfr | Provided Java Flight Recorder (JFR) events for test discovery and execution | JFR support is now built into the launcher itself; no separate module needed |
| junit-platform-suite-commons | Internal commons for suite support | Merged into junit-platform-suite-api |
| MethodOrderer.Alphanumeric | Alphabetical test method ordering | Use MethodOrderer.DEFAULT or MethodOrderer.MethodName |
| JUnit Vintage (deprecated) | Runs JUnit 3/4 tests | Still present but deprecated; will be removed in a future major version |
// JUnit 5: @RunWith(JUnitPlatform.class) -- REMOVED @RunWith(JUnitPlatform.class) // Does not exist in JUnit 6 public class MyJUnit4Suite { ... } // JUnit 6: use @Suite directly @Suite @SelectClasses({OrderTest.class, UserTest.class}) public class MyTestSuite { } // JUnit 5: MethodOrderer.Alphanumeric -- REMOVED @TestMethodOrder(MethodOrderer.Alphanumeric.class) // Compile error in JUnit 6 class OldOrderedTest { ... } // JUnit 6: use MethodOrderer.MethodName instead @TestMethodOrder(MethodOrderer.MethodName.class) class OrderedTest { ... } // Check for removed APIs before upgrading: // grep -r "JUnitPlatform\|Alphanumeric\|platform-runner\|platform-jfr" src/
14. How do you migrate from JUnit 5 to JUnit 6?
For teams already on JUnit 5.14 and Java 17+, the JUnit team describes the JUnit 6 migration as a "routine dependency bump". The annotation model is unchanged; what breaks is removed APIs and stricter CSV parsing.
<!-- Step 1: Update the BOM version in pom.xml --> <dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>6.1.1</version> <!-- was 5.14.x --> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <!-- Step 2: Update Maven Surefire/Failsafe to 3.x --> <!-- (versions older than 3.0.0 are no longer supported by JUnit 6) --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.5.0</version> </plugin> <!-- Gradle: --> // build.gradle.kts dependencies { testImplementation(platform("org.junit:junit-bom:6.1.1")) testImplementation("org.junit.jupiter:junit-jupiter") } tasks.test { useJUnitPlatform() } /* Step 3: Fix compiler errors from removed APIs: - Replace Alphanumeric with MethodName - Remove junit-platform-runner dependency - Remove @RunWith(JUnitPlatform.class) usages - Fix malformed @CsvSource data (extra chars after quotes) - Ensure Java 17+ baseline Step 4: Run the test suite and fix any FastCSV-related failures from stricter CSV parsing */
15. What are assumptions in JUnit 6 and how do they differ from assertions?
Assumptions are conditions checked at the start of a test. If an assumption fails, the test is aborted (skipped) rather than failed. Assumptions are used to skip tests that are not meaningful in a particular environment (e.g. running only on Linux, only with network access, only when a feature flag is enabled).
import static org.junit.jupiter.api.Assumptions.*; class AssumptionsDemo { @Test void runOnlyOnCi() { // Abort (skip) the test if CI environment variable is not set assumeTrue("true".equals(System.getenv("CI")), "Skipping: not running in CI environment"); // Test only executes here if the assumption held performSlowIntegrationTest(); } @Test void runOnlyOnLinux() { assumeTrue(System.getProperty("os.name").startsWith("Linux")); // OS-specific test code } @Test void assumptionWithSupplierMessage() { // Lazy message evaluation String env = System.getenv("APP_ENV"); assumeFalse("production".equals(env), () -> "Skipping destructive test in env: " + env); cleanDatabase(); } @Test void runSubsetOnlyWhenDatabaseAvailable() { // assumingThat: run a block only if condition holds // but do NOT abort the whole test boolean dbAvailable = isDatabaseAvailable(); assumingThat(dbAvailable, () -> { // Only this block is skipped if db is unavailable assertDbRecordExists("user-1"); }); // This assertion always runs: assertFalse(dbAvailable && isReadOnly()); } }
| Aspect | Assertion (assertXxx) | Assumption (assumeXxx) |
|---|---|---|
| Failure effect | Test FAILS (red) | Test ABORTED/skipped (grey) |
| Purpose | Verify correctness | Guard against meaningless environments |
| Report visibility | Always shown as failure | Shown as skipped/aborted |
16. What is @TestInstance and how does it change test class lifecycle?
By default, JUnit creates a new instance of the test class for each test method. @TestInstance(Lifecycle.PER_CLASS) changes this so a single instance is shared across all test methods in the class.
// Default: PER_METHOD (new instance per test) class DefaultLifecycleTest { int count = 0; @Test void first() { count++; assertEquals(1, count); } // pass @Test void second() { count++; assertEquals(1, count); } // pass // Each @Test gets its own instance, so count starts at 0 each time } // PER_CLASS: single shared instance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SharedInstanceTest { int count = 0; @Test @Order(1) void first() { count++; assertEquals(1, count); } @Test @Order(2) void second() { count++; assertEquals(2, count); } // Same instance: count accumulates across tests // Benefits of PER_CLASS: // 1. @BeforeAll and @AfterAll can be NON-STATIC @BeforeAll void setUpAll() { // no static required! database = Database.connect(); } @AfterAll void tearDownAll() { // no static required! database.close(); } // 2. Shared expensive state (database, server) // without static fields Database database; }
| Aspect | PER_METHOD (default) | PER_CLASS |
|---|---|---|
| Instances created | One per test method | One per test class |
| @BeforeAll/@AfterAll must be | static | Can be non-static |
| Test isolation | High (fresh instance per test) | Lower (shared mutable state) |
| Use case | Unit tests | Integration tests with shared expensive resources |
17. What is the @RegisterExtension annotation and how does it differ from @ExtendWith?
@ExtendWith registers an extension via a class reference (declarative, at compile time). @RegisterExtension registers an extension via a field instance at runtime, allowing the extension to be configured with constructor parameters.
// @ExtendWith: declarative, no configuration @ExtendWith(MockitoExtension.class) class SimpleTest { ... } // @RegisterExtension: programmatic, configurable class ConfigurableTest { // Static field: extension scope covers ALL tests (like @BeforeAll) @RegisterExtension static WireMockExtension server = WireMockExtension.newInstance() .options(WireMockConfiguration.options().port(8089)) .build(); // Instance field: extension scope covers EACH test (like @BeforeEach) @RegisterExtension TempDirectory tempDir = new TempDirectory(); @Test void callsExternalApi() { server.stubFor(get("/orders") .willReturn(aResponse().withStatus(200))); // test code } @Test void writesToTempFile() throws IOException { Path file = tempDir.getRoot().resolve("output.txt"); Files.writeString(file, "hello"); assertEquals("hello", Files.readString(file)); } } // Key rule: static @RegisterExtension field // -> extension callbacks run around the class (BeforeAll/AfterAll) // Instance @RegisterExtension field // -> callbacks run around each test (BeforeEach/AfterEach)
18. What is the @TempDir annotation in JUnit 6?
@TempDir is a built-in JUnit 6 extension (from junit-jupiter-api) that creates a temporary directory for a test and automatically deletes it afterwards. It eliminates the boilerplate of creating, using, and deleting temp directories manually.
import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; import java.io.File; class TempDirTest { // Method parameter injection: per-test temp directory @Test void writesAndReadsFile(@TempDir Path tempDir) throws Exception { Path file = tempDir.resolve("report.txt"); Files.writeString(file, "Test output"); assertEquals("Test output", Files.readString(file)); // Directory is automatically deleted after the test } // Field injection: same directory reused across tests in class @TempDir static Path sharedTempDir; // static = shared across all tests @Test void firstTestUsesSharedDir() throws Exception { Files.writeString(sharedTempDir.resolve("a.txt"), "hello"); } @Test void secondTestSeesFilesFromFirst() throws Exception { // sharedTempDir persists between tests (static) assertTrue(Files.exists(sharedTempDir.resolve("a.txt"))); } // Can also inject as java.io.File: @Test void withFileType(@TempDir File tempDir) { File output = new File(tempDir, "result.csv"); // ... file operations } }
19. What are dynamic tests in JUnit 6 and how do you create them with @TestFactory?
Dynamic tests are tests generated at runtime rather than defined at compile time. They are created using @TestFactory methods that return a Stream, Collection, or Iterable of DynamicTest objects.
import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import static org.junit.jupiter.api.DynamicTest.dynamicTest; class DynamicTestsDemo { // Basic: generate tests from a list at runtime @TestFactory Stream<DynamicTest> dynamicAdditionTests() { return Stream.of( dynamicTest("1 + 1 = 2", () -> assertEquals(2, 1 + 1)), dynamicTest("5 + 3 = 8", () -> assertEquals(8, 5 + 3)), dynamicTest("-1 + 1 = 0", () -> assertEquals(0, -1 + 1)) ); } // Useful: load test data from a file, database, or API at runtime @TestFactory Stream<DynamicTest> testsFromDatabase() { return testDataRepository.findAll().stream() .map(data -> dynamicTest( "Validate: " + data.id(), () -> { Result result = service.process(data); assertEquals(data.expectedStatus(), result.status()); assertNotNull(result.processedAt()); } )); } // Dynamic node containers (hierarchical dynamic tests) @TestFactory Stream<DynamicNode> hierarchical() { return Stream.of( dynamicContainer("Positive numbers", Stream.of( dynamicTest("1 is positive", () -> assertTrue(1 > 0)), dynamicTest("100 is positive", () -> assertTrue(100 > 0)) ) ) ); } }
Key distinction: @ParameterizedTest tests are registered at discovery time (the count is known before execution). Dynamic tests are created at runtime -- the count may not be known until the @TestFactory method is called.
20. How does parallel test execution work in JUnit 6?
JUnit 6 supports running tests in parallel to reduce total test suite execution time. It must be enabled via configuration and provides fine-grained control over which tests run in parallel.
# Enable parallel execution in junit-platform.properties # (src/test/resources/junit-platform.properties) # Enable parallelism globally junit.jupiter.execution.parallel.enabled=true # Strategy: dynamic (uses available processors) junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=1.5 # OR: fixed thread count junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=4 # Default mode: all test classes run sequentially, # all methods within a class run sequentially. # Override with: junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.mode.classes.default=concurrent
// @Execution annotation: fine-grained control per class or method import org.junit.jupiter.api.parallel.*; @Execution(ExecutionMode.CONCURRENT) // this class runs in parallel class FastParallelTest { @Test void first() { ... } @Test void second() { ... } } @Execution(ExecutionMode.SAME_THREAD) // always sequential class OrderDependentTest { @Test @Order(1) void setup() { ... } @Test @Order(2) void execute(){ ... } } // @ResourceLock: prevent concurrent access to shared resources class DatabaseTest { @Test @ResourceLock(value = "database", mode = ResourceAccessMode.READ_WRITE) void writesToDatabase() { ... } @Test @ResourceLock(value = "database", mode = ResourceAccessMode.READ) void readsFromDatabase() { ... } // Reads run concurrently with each other // Writes are exclusive (no concurrency with reads or other writes) }
21. What is @ParameterizedClass in JUnit 6 and how does it differ from @ParameterizedTest?
@ParameterizedClass (introduced in JUnit 5.13 and fully supported in JUnit 6) parameterises an entire test class rather than a single method. All test methods in the class run for each set of arguments. This is ideal when multiple tests share the same setup conditions that vary per invocation.
import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.api.*; // @ParameterizedClass: the ENTIRE class runs once per argument @ParameterizedClass @MethodSource("locales") @DisplayName("Cart service in locale") class CartServiceLocaleTest { // Arguments injected via constructor or field private final Locale locale; CartServiceLocaleTest(Locale locale) { this.locale = locale; } static Stream<Locale> locales() { return Stream.of(Locale.US, Locale.UK, Locale.DE); } @Test void currencySymbolIsCorrect() { CartService svc = new CartService(locale); assertNotNull(svc.getCurrencySymbol()); } @Test void priceFormattingMatchesLocale() { CartService svc = new CartService(locale); String price = svc.format(9.99); assertTrue(price.contains(svc.getCurrencySymbol())); } } // Runs 2 tests x 3 locales = 6 test invocations total // Compare with @ParameterizedTest (method-level): class CartServiceTest { @ParameterizedTest @MethodSource("locales") void currencySymbolIsCorrect(Locale locale) { // single method only assertNotNull(new CartService(locale).getCurrencySymbol()); } }
| Aspect | @ParameterizedTest | @ParameterizedClass |
|---|---|---|
| Scope | Single test method | Entire test class (all @Test methods) |
| Arguments injected via | Method parameter | Constructor or field injection |
| Use case | One test scenario with multiple inputs | Multiple tests sharing the same parameterised setup |
22. What FastCSV migration happened in JUnit 6 and what are the breaking changes?
JUnit 6 replaced the univocity-parsers library (used for CSV parsing in @CsvSource and @CsvFileSource) with FastCSV. This was necessary because univocity-parsers became unmaintained. FastCSV is faster and stricter, which causes a few breaking changes for tests with previously tolerated malformed CSV.
| Change | JUnit 5 behaviour | JUnit 6 behaviour |
|---|---|---|
| Extra characters after closing quote | Silently tolerated | Throws exception: 'foo'INVALID,'bar' is invalid |
| lineSeparator attribute in @CsvFileSource | Configurable | Removed: auto-detected (\r, \n, \r\n all work) |
| Header field processing | ignoreLeadingAndTrailingWhitespace etc. applied to data only | Now apply to header fields as well |
| Comment character in @CsvSource | # was default, no way to change it | New commentCharacter attribute; defaults to # but now configurable |
| Exception messages for malformed CSV | Implementation-specific | May differ with FastCSV's error messages |
// JUnit 5: this was silently tolerated (malformed CSV) @CsvSource({"'foo'INVALID,'bar'"}) void test(String a, String b) { ... } // ^ JUnit 6: throws exception -- extra chars after closing quote // JUnit 6: lineSeparator removed -- auto-detected // JUnit 5: @CsvFileSource(resources = "/data.csv", lineSeparator = "\n") // JUnit 6: @CsvFileSource(resources = "/data.csv") // lineSeparator attribute gone // JUnit 6: new commentCharacter attribute (needed if # appears in data) @CsvSource(value = { "#this is a comment -- skipped", "1, 2, 3", }, commentCharacter = '#') void additionTest(int a, int b, int expected) { ... } // Conflicting delimiter bug fix (from 6.0.1): // A regression in 6.0.0 caused exception when delimiter=# in @CsvSource // because # was ALSO the comment character with no way to override. // 6.0.1 added commentCharacter attribute to fix this.
23. What is the TestInstance lifecycle in JUnit 6 and what are the two modes?
By default JUnit 6 creates a new test class instance for each test method (PER_METHOD). The @TestInstance annotation lets you change this to PER_CLASS, where one instance is shared across all methods in the class.
| Mode | Annotation | Behaviour | Implication |
|---|---|---|---|
| PER_METHOD (default) | None needed | New instance per @Test method | Test isolation guaranteed; @BeforeAll/@AfterAll must be static |
| PER_CLASS | @TestInstance(Lifecycle.PER_CLASS) | Single instance for entire class | Instance state can accumulate across tests; @BeforeAll/@AfterAll can be non-static |
// PER_METHOD (default): new instance per test class DefaultLifecycleTest { int counter = 0; @Test void firstTest() { counter++; assertEquals(1, counter); } @Test void secondTest() { counter++; assertEquals(1, counter); } // passes! // Each test gets a fresh instance with counter=0 } // PER_CLASS: single shared instance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SharedInstanceTest { int counter = 0; @Test void firstTest() { counter++; assertEquals(1, counter); } @Test void secondTest() { counter++; assertEquals(2, counter); } // passes! // Shared instance: counter carries over // @BeforeAll and @AfterAll can be non-static: @BeforeAll void initAll() { System.out.println("Runs once, non-static -- only possible with PER_CLASS"); } } // PER_CLASS is common for @Nested tests where // the outer class must hold shared state accessed by inner classes.
24. How does test ordering work in JUnit 6 with @TestMethodOrder?
By default JUnit 6 uses a deterministic but non-obvious method order. @TestMethodOrder lets you explicitly control execution order. In JUnit 6, this annotation is recursively inherited by @Nested classes.
| Orderer | Behaviour |
|---|---|
| MethodOrderer.OrderAnnotation | Methods run in @Order(n) value order (ascending) |
| MethodOrderer.DisplayName | Methods run in display name alphanumeric order |
| MethodOrderer.MethodName | Methods run in method name alphanumeric order |
| MethodOrderer.Random | Methods run in random order (with optional seed for reproducibility) |
| MethodOrderer.Alphanumeric | Removed in JUnit 6: replaced by DisplayName or MethodName |
import org.junit.jupiter.api.*; @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class OrderedTest { @Test @Order(1) void firstStep() { System.out.println("1"); } @Test @Order(2) void secondStep() { System.out.println("2"); } @Test @Order(3) void thirdStep() { System.out.println("3"); } // JUnit 6: @TestMethodOrder is inherited by @Nested classes @Nested class NestedSteps { // No @TestMethodOrder needed -- inherited from outer class @Test @Order(1) void nestedFirst() { System.out.println("N1"); } @Test @Order(2) void nestedSecond() { System.out.println("N2"); } } } // Random order with seed (reproducible): @TestMethodOrder(MethodOrderer.Random.class) class RandomOrderTest { // Set seed for reproducible random: // junit.jupiter.execution.order.random.seed=42 // in junit-platform.properties }
Breaking change: MethodOrderer.Alphanumeric was deprecated in JUnit 5 and removed in JUnit 6. Use MethodOrderer.DisplayName or MethodOrderer.MethodName instead.
25. What modules were removed in JUnit 6 and why?
JUnit 6 removed several modules that had been deprecated in earlier JUnit 5.x releases. Knowing what was removed and its replacement is a common interview topic.
| Removed module | What it did | Replacement |
|---|---|---|
| junit-platform-runner | JUnit 4 @RunWith-based runner that could run JUnit Platform tests on JUnit 4 engines | Migrate to native JUnit 6 engine; use Maven Surefire 3.x or Gradle's JUnit Platform support directly |
| junit-platform-jfr | Provided custom Java Flight Recorder (JFR) events for test discovery and execution | JFR integration is now built into the launcher itself -- no separate module needed |
| junit-platform-suite-commons | Internal helper module for test suite support | Functionality merged into junit-platform-suite |
| junit-jupiter-migrationsupport | Compatibility layer for JUnit 4 @Rule adapters in JUnit 5 | Deprecated in JUnit 6.0.0; will be removed in the next major version |
| junit-vintage-engine (deprecated, not removed) | Runs JUnit 3/4 tests on the JUnit Platform | Still present but deprecated; remove JUnit 3/4 tests or use an interim Vintage dependency |
<!-- BEFORE (JUnit 5 with platform runner) --> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-runner</artifactId> <!-- REMOVED in JUnit 6 --> <scope>test</scope> </dependency> <!-- AFTER (JUnit 6): use Maven Surefire 3.x directly --> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.3.0</version> <!-- Maven Surefire 3.x required for JUnit 6 --> </plugin> <!-- Surefire 3.x discovers JUnit Platform tests natively without needing junit-platform-runner --> <!-- jupiter-migrationsupport: deprecated but still present in 6.0.x Remove before JUnit 7 ships --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-migrationsupport</artifactId> <!-- DEPRECATED --> <scope>test</scope> </dependency>
26. How do you migrate from JUnit 5 to JUnit 6?
Migrating from JUnit 5 to JUnit 6 is significantly simpler than the JUnit 4 to 5 migration. Because the Jupiter annotation model is unchanged, most projects require only a version bump plus addressing a handful of breaking changes.
| Step | Action |
|---|---|
| 1. Update dependencies | Change junit-bom version from 5.x.x to 6.1.1; remove explicit version overrides |
| 2. Java 17 baseline | Ensure the project targets Java 17 or higher (JUnit 6 requires it) |
| 3. Remove deleted modules | Remove junit-platform-runner and junit-platform-jfr dependencies |
| 4. Fix CSV tests | Review @CsvSource and @CsvFileSource tests for malformed CSV (FastCSV is stricter) |
| 5. Remove lineSeparator | Remove lineSeparator attribute from @CsvFileSource (no longer exists) |
| 6. Fix Alphanumeric order | Replace MethodOrderer.Alphanumeric with DisplayName or MethodName |
| 7. Update build tools | Upgrade to Maven Surefire 3.x and Gradle 8.x for JUnit 6 support |
| 8. Update Kotlin | Upgrade to Kotlin 2.2+ minimum if using Kotlin tests |
| 9. Kotlin suspend tests | Remove runBlocking wrappers from suspend test methods |
| 10. Review migrationsupport | Remove junit-jupiter-migrationsupport usage (deprecated) |
| 11. Unified version | The Platform version is now 6.x.x, same as Jupiter -- update any explicit references |
<!-- Maven migration: change the BOM version --> <!-- BEFORE (JUnit 5) --> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>5.11.4</version> <!-- old --> <type>pom</type><scope>import</scope> </dependency> <!-- AFTER (JUnit 6) --> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>6.1.1</version> <!-- updated --> <type>pom</type><scope>import</scope> </dependency> <!-- Kotlin tests: remove runBlocking --> // BEFORE @Test fun fetchUser(): Unit = runBlocking { val u = service.fetchUser("1") assertEquals("Alice", u.name) } // AFTER @Test suspend fun fetchUser() { val u = service.fetchUser("1") assertEquals("Alice", u.name) }
27. What are assumptions in JUnit 6 and how do they differ from assertions?
Assumptions abort a test silently (marking it as aborted) when a condition is not met. Assertions fail the test with a failure when a condition is not met. Use assumptions to skip tests that are irrelevant in the current environment rather than to verify business logic.
import static org.junit.jupiter.api.Assumptions.*; class AssumptionsDemo { @Test void runOnlyOnLinux() { assumeTrue(System.getProperty("os.name").toLowerCase().contains("linux"), "Skipped: not running on Linux"); // Test body: only executes on Linux // On Windows/macOS: test is ABORTED (not failed) } @Test void runOnlyInCiEnvironment() { // assumingThat: execute body only if condition true; continue otherwise assumingThat( "CI".equals(System.getenv("ENV")), () -> { // This block runs only in CI verifyDeploymentHealth(); } ); // This line always runs regardless of the assumption assertEquals(1, 1); } @Test void skipOnNullDatabase() { assumeFalse(System.getenv("DB_URL") == null, "Skipped: DB_URL not configured"); // Integration test body } }
| Aspect | Assumptions | Assertions |
|---|---|---|
| Failure mode | Test aborted (grey in reports) | Test failed (red in reports) |
| Use case | Skip irrelevant test in current environment | Verify correctness of production logic |
| Methods | assumeTrue, assumeFalse, assumingThat | assertEquals, assertTrue, assertThrows etc. |
| Effect on suite | Skipped: does not count as pass or fail | Failure: counts against the build |
28. What is the @TempDir annotation and how has it changed in JUnit 6?
@TempDir creates a temporary directory for a test and automatically deletes it after the test completes. JUnit 6 adds a cleanup attribute to control when (and whether) the directory is deleted, enabling inspection of files after a failing test.
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.CleanupMode; class TempDirDemo { // Field injection: directory shared across all tests in class @TempDir static Path sharedTempDir; // Parameter injection: fresh directory per test method @Test void writeAndReadFile(@TempDir Path tempDir) throws Exception { Path file = tempDir.resolve("output.txt"); Files.writeString(file, "Hello JUnit 6"); assertEquals("Hello JUnit 6", Files.readString(file)); // tempDir is automatically deleted after this test } // JUnit 6: cleanup attribute -- control when directory is deleted @Test void keepDirOnFailure( @TempDir(cleanup = CleanupMode.ON_SUCCESS) Path debugDir ) throws Exception { Files.writeString(debugDir.resolve("log.txt"), "debug info"); // If test PASSES: directory is deleted // If test FAILS: directory is KEPT for inspection } // cleanup modes: // ALWAYS (default): always delete // ON_SUCCESS: keep on failure (useful for debugging) // NEVER: never delete (manual cleanup) }
| Mode | When deleted |
|---|---|
| CleanupMode.ALWAYS | Always deleted after test (default) |
| CleanupMode.ON_SUCCESS | Only deleted if test passes; kept on failure for debugging |
| CleanupMode.NEVER | Never automatically deleted |
29. How does parallel test execution work in JUnit 6?
JUnit 6 supports parallel test execution at both the class and method level. It is disabled by default and is configured via junit-platform.properties or programmatic launcher configuration. The implementation uses Java's ForkJoinPool.
# junit-platform.properties (in src/test/resources/) # Enable parallel execution junit.jupiter.execution.parallel.enabled=true # Strategy: DYNAMIC (based on CPU count) or FIXED (explicit thread count) junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=2 # thread count = processor count * factor (e.g. 4 cores * 2 = 8 threads) # OR: fixed thread count # junit.jupiter.execution.parallel.config.strategy=fixed # junit.jupiter.execution.parallel.config.fixed.parallelism=4 # Default execution mode: SAME_THREAD (sequential) or CONCURRENT junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.mode.classes.default=concurrent
// Fine-grained control with @Execution: import org.junit.jupiter.api.parallel.*; @Execution(ExecutionMode.CONCURRENT) // This class runs in parallel class ParallelTests { @Test void test1() { /* can run in parallel with test2 */ } @Test void test2() { /* can run in parallel with test1 */ } } @Execution(ExecutionMode.SAME_THREAD) // This class runs sequentially class SequentialTests { // Useful for tests with shared mutable state or DB transactions } // @ResourceLock: prevent concurrent access to named resources @ResourceLock(value = "database", mode = ResourceAccessMode.READ_WRITE) void dbWriteTest() { /* exclusive DB access */ } @ResourceLock(value = "database", mode = ResourceAccessMode.READ) void dbReadTest() { /* shared read access -- can run with other READ tests */ }
30. What is the difference between @ExtendWith and @RegisterExtension in JUnit 6?
JUnit 6 provides two ways to register extensions. The choice between them depends on whether you need programmatic construction (with constructor arguments) or simple declarative annotation use.
| Aspect | @ExtendWith | @RegisterExtension |
|---|---|---|
| Location | Annotation on class or method | Field in the test class |
| Extension construction | JUnit calls default no-arg constructor | Developer constructs with custom arguments |
| When to use | Stateless extensions with no configuration | Extensions that need constructor parameters or runtime configuration |
| Lifecycle control | Limited | Full: static field = class scope; instance field = method scope |
| Example | @ExtendWith(MockitoExtension.class) | @RegisterExtension static WireMockExtension wm = WireMockExtension.newInstance().port(8080).build(); |
// @ExtendWith: declarative, no constructor args @ExtendWith(MockitoExtension.class) class MockitoTest { @Mock UserRepository repo; @Test void test() { ... } } // @RegisterExtension: programmatic, with constructor args class WireMockTest { // Static field -> class-scoped lifecycle (like @BeforeAll/@AfterAll) @RegisterExtension static WireMockExtension wireMock = WireMockExtension.newInstance() .options(wireMockConfig().port(8080)) .build(); // Instance field -> method-scoped lifecycle (fresh per test) @RegisterExtension DatabaseExtension db = new DatabaseExtension("jdbc:h2:mem:test"); @Test void callsExternalService() { wireMock.stubFor(get("/api/users").willReturn(okJson("[]"))); // ... } } // Multiple extensions can be combined: @ExtendWith({MockitoExtension.class, SpringExtension.class}) class SpringMockitoTest { ... }
31. What is the @RepeatedTest annotation in JUnit 6?
@RepeatedTest(n) runs a test method exactly n times. Each repetition is treated as an independent test. It is useful for verifying non-deterministic behaviour, testing concurrency, or stress-testing time-sensitive operations.
import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.RepetitionInfo; class RepeatedTestDemo { // Simple: run exactly 5 times @RepeatedTest(5) void repeatedTest() { assertTrue(Math.random() >= 0.0); } // Custom display name with placeholders: // {displayName}, {currentRepetition}, {totalRepetitions} @RepeatedTest( value = 5, name = "Repetition {currentRepetition} of {totalRepetitions}" ) void namedRepetitions() { // Runs 5 times with names: "Repetition 1 of 5", "Repetition 2 of 5"... } // Inject RepetitionInfo to know which repetition you are in: @RepeatedTest(3) void withRepetitionInfo(RepetitionInfo info) { System.out.printf("Running repetition %d of %d%n", info.getCurrentRepetition(), info.getTotalRepetitions()); // Common pattern: fail only on last repetition // to verify eventual consistency: if (info.getCurrentRepetition() == info.getTotalRepetitions()) { assertEquals("done", checkEventualState()); } } } // Common use cases for @RepeatedTest: // - Flaky test isolation (run 10x to catch intermittent failures) // - Cache warm-up tests (first invocation is slow, later are fast) // - Thread safety tests (repeated concurrent invocations) // - Eventual consistency checks (state converges by Nth repetition)
32. What is the ExtensionContext and its Store used for in JUnit 6?
The ExtensionContext is the primary object through which extensions interact with the JUnit 6 runtime. It provides access to the current test method, class, display name, tags, and a key-value Store for sharing data between extension callbacks.
// ExtensionContext.Store: share data between callbacks within an extension public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { // Namespace prevents collisions between multiple extensions private static final Namespace NS = Namespace.create(TimingExtension.class); @Override public void beforeTestExecution(ExtensionContext ctx) { // Store the start time under this test's context getStore(ctx).put("startTime", System.currentTimeMillis()); } @Override public void afterTestExecution(ExtensionContext ctx) { long startTime = getStore(ctx).remove("startTime", long.class); long elapsed = System.currentTimeMillis() - startTime; System.out.printf("%s took %d ms%n", ctx.getDisplayName(), elapsed); } private ExtensionContext.Store getStore(ExtensionContext ctx) { return ctx.getStore(NS); } } // ExtensionContext key methods: // ctx.getDisplayName() -- test display name // ctx.getRequiredTestMethod() -- the @Test Method object // ctx.getRequiredTestClass() -- the test class // ctx.getTags() -- Set<String> of @Tag values // ctx.getTestInstance() -- Optional<Object> test instance // ctx.getCancellationToken() -- JUnit 6: cooperative cancellation // ctx.getStore(namespace) -- scoped key-value store // ctx.getRoot() -- root context (global scope)
33. What are tags and filtering in JUnit 6 and how are they used?
Tags categorise tests. At build time you can include or exclude specific tags, enabling selective test execution (e.g. run only fast tests in a pre-commit hook, all tests in CI).
// Annotate tests with @Tag @Tag("fast") @Tag("unit") class FastUnitTest { @Test void test1() { ... } } @Tag("slow") @Tag("integration") class SlowIntegrationTest { @Test void dbTest() { ... } } // Composed annotation to avoid repetition: @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Tag("fast") @Tag("unit") public @interface UnitTest { } @UnitTest // same as @Tag("fast") @Tag("unit") class OrderServiceTest { ... }
<!-- Maven Surefire: run only fast unit tests --> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.3.0</version> <configuration> <groups>fast & unit</groups> <excludedGroups>slow | integration</excludedGroups> </configuration> </plugin> <!-- ConsoleLauncher: tag expression syntax --> java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --include-tag "fast" \ --exclude-tag "slow" # Gradle: filter by tag test { useJUnitPlatform { includeTags "fast" excludeTags "slow", "integration" } }
34. What are dynamic tests in JUnit 6 and how does @TestFactory work?
Dynamic tests are generated at runtime rather than being declared statically as annotated methods. The @TestFactory method returns a collection or stream of DynamicTest or DynamicContainer objects, each with its own name and executable.
import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import static org.junit.jupiter.api.DynamicTest.dynamicTest; class DynamicTestDemo { // @TestFactory: generates tests at runtime from a Stream @TestFactory Stream<DynamicTest> dynamicTestsFromStream() { return Stream.of("apple", "banana", "cherry") .map(fruit -> dynamicTest( "Is non-empty: " + fruit, () -> assertFalse(fruit.isEmpty()) ) ); } // Generate tests from external data source (database, file) @TestFactory Stream<DynamicTest> testsFromDatabase() { return testDataRepository.findAll().stream() .map(scenario -> dynamicTest( scenario.getName(), () -> { Result result = service.process(scenario.getInput()); assertEquals(scenario.getExpected(), result); } )); } // DynamicContainer: nested dynamic structure @TestFactory Stream<DynamicNode> nestedDynamic() { return Stream.of("cats", "dogs") .map(category -> DynamicContainer.dynamicContainer( "Tests for " + category, Stream.of( dynamicTest("has species", () -> assertNotNull(category)), dynamicTest("is not empty", () -> assertFalse(category.isEmpty())) ) )); } }
35. How does JUnit 6 integrate with Maven and Gradle?
JUnit 6 requires updated build tool versions that support the JUnit Platform. Maven needs Surefire 3.x and Gradle needs 8.x.
<!-- Maven: complete JUnit 6 setup --> <properties> <java.version>17</java.version> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>6.1.1</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> </dependencies> <build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.3.0</version> <!-- 3.x required for JUnit 6 --> </plugin> </plugins> </build>
// Gradle: complete JUnit 6 setup (build.gradle.kts) plugins { java } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } dependencies { testImplementation(platform("org.junit:junit-bom:6.1.1")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { useJUnitPlatform() // Required: tells Gradle to use JUnit Platform maxParallelForks = Runtime.getRuntime().availableProcessors() }
36. What is the @Suite API in JUnit 6 and how do you group tests into suites?
JUnit 6 provides a @Suite annotation (in junit-platform-suite) for declaratively grouping tests from multiple classes or packages. This replaces JUnit 4's @RunWith(Suite.class) and JUnit Platform Runner (which was removed in JUnit 6).
import org.junit.platform.suite.api.*; // Basic suite: select specific test classes @Suite @SelectClasses({ OrderServiceTest.class, PaymentServiceTest.class, InventoryServiceTest.class }) class SmokeTestSuite {} // Suite by package (runs all tests in the package) @Suite @SelectPackages({"com.example.unit", "com.example.integration"}) class AllTestsSuite {} // Suite with tag filtering @Suite @SelectPackages("com.example") @IncludeTags("fast") @ExcludeTags({"slow", "integration"}) class FastTestSuite {} // Suite with class name pattern @Suite @SelectPackages("com.example") @IncludeClassNamePatterns(".*IT") // only classes ending in IT class IntegrationTestSuite {} // Maven: run suites explicitly // <configuration> // <includes> // <include>**/*Suite.class</include> // </includes> // </configuration>
Important: suite classes themselves contain no test methods. They are purely declarative configuration classes. The @Suite annotation is a signal to the JUnit Platform to aggregate and run the selected tests.
37. What is the ParameterResolver extension interface and how do you use it for custom injection?
The ParameterResolver extension interface allows you to inject custom objects into test method parameters, @BeforeEach methods, and constructors. This is the foundation of how MockitoExtension injects @Mock objects and how SpringExtension injects beans.
// Custom ParameterResolver: inject a configured HttpClient public class HttpClientExtension implements ParameterResolver, BeforeEachCallback, AfterEachCallback { private final Map<ExtensionContext, HttpClient> clients = new ConcurrentHashMap<>(); @Override public boolean supportsParameter( ParameterContext paramCtx, ExtensionContext extCtx) { // Only resolve HttpClient parameters return paramCtx.getParameter().getType() == HttpClient.class; } @Override public Object resolveParameter( ParameterContext paramCtx, ExtensionContext extCtx) { // Create and return an HttpClient for this test HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); clients.put(extCtx, client); return client; } @Override public void afterEach(ExtensionContext ctx) { HttpClient client = clients.remove(ctx); if (client != null) client.close(); } } // Usage: @ExtendWith(HttpClientExtension.class) class ApiIntegrationTest { @Test void fetchUsers(HttpClient client) { // injected by extension! HttpResponse<String> resp = client.send( HttpRequest.newBuilder(URI.create("https://api.example.com/users")).build(), HttpResponse.BodyHandlers.ofString() ); assertEquals(200, resp.statusCode()); } }
38. What is conditional test execution in JUnit 6 and what built-in conditions are available?
JUnit 6 provides condition annotations that skip or enable tests based on runtime conditions without writing assumption code inside the test body. These are implemented as extensions using the ExecutionCondition interface.
| Annotation | Skips test when... |
|---|---|
| @EnabledOnOs / @DisabledOnOs | Running on a specific OS (e.g. WINDOWS, LINUX, MAC) |
| @EnabledOnJre / @DisabledOnJre | Running on a specific Java version range |
| @EnabledForJreRange / @DisabledForJreRange | JRE is inside or outside a specified version range |
| @EnabledIfSystemProperty / @DisabledIfSystemProperty | A system property matches (or doesn't match) a regex |
| @EnabledIfEnvironmentVariable | An environment variable matches a regex |
| @EnabledIf / @DisabledIf | A static boolean method returns false / true |
import org.junit.jupiter.api.condition.*; class ConditionalTests { @Test @EnabledOnOs(OS.LINUX) void linuxOnlyTest() { ... } @Test @DisabledOnOs({OS.WINDOWS, OS.MAC}) void notOnWindowsOrMac() { ... } @Test @EnabledOnJre(JRE.JAVA_21) void java21Feature() { ... } @Test @EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21) void java17to21Only() { ... } @Test @EnabledIfEnvironmentVariable(named = "ENV", matches = "CI") void ciOnlyTest() { ... } @Test @EnabledIfSystemProperty(named = "db.available", matches = "true") void databaseTest() { ... } // @EnabledIf: call a custom static method @Test @EnabledIf("isWeekend") void weekendTest() { ... } static boolean isWeekend() { DayOfWeek day = LocalDate.now().getDayOfWeek(); return day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY; } }
39. What are test interfaces and default methods in JUnit 6?
JUnit 6 supports placing @Test, @BeforeEach, @AfterEach, and other annotations on interface default methods. Test classes implementing the interface automatically inherit those tests and lifecycle methods. This is useful for contract testing and shared test behaviour.
// Define a reusable contract test as an interface public interface ComparableContract<T extends Comparable<T>> { T createEqualValue(); T createSmallerValue(); T createLargerValue(); @Test default void reflexive() { T val = createEqualValue(); assertEquals(0, val.compareTo(createEqualValue())); } @Test default void symmetricForEqual() { T a = createEqualValue(); T b = createEqualValue(); assertEquals(0, a.compareTo(b)); assertEquals(0, b.compareTo(a)); } @Test default void smallerIsLessThanLarger() { assertTrue(createSmallerValue().compareTo(createLargerValue()) < 0); } } // Implementing classes get all contract tests for free: class StringComparableTest implements ComparableContract<String> { @Override public String createEqualValue() { return "hello"; } @Override public String createSmallerValue() { return "apple"; } @Override public String createLargerValue() { return "zebra"; } // Also free to add class-specific @Test methods @Test void stringsAreCaseSensitive() { assertNotEquals(0, "Hello".compareTo("hello")); } } // Any class implementing ComparableContract gets all 3 contract tests.
40. How does JUnit 6 interact with Mockito and Spring Test?
JUnit 6 integrates with major testing frameworks via its Extension Model. Mockito and Spring Test both provide JUnit 6-compatible extensions.
// Mockito with JUnit 6: // Use MockitoExtension (works identically to JUnit 5) @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository repository; @InjectMocks OrderService service; @Test void createsOrder() { when(repository.save(any())).thenReturn(new Order("O-1", Status.PENDING)); Order order = service.create("item-1", 2); assertEquals(Status.PENDING, order.getStatus()); verify(repository).save(any(Order.class)); } } // Spring Boot Test with JUnit 6: @SpringBootTest class ApplicationIntegrationTest { // SpringExtension is auto-registered by @SpringBootTest @Autowired OrderService service; @Test void contextLoads() { assertNotNull(service); } } // Spring with Mockito beans: @SpringBootTest class OrderControllerTest { @Autowired MockMvc mockMvc; @MockBean // Spring-managed mock -- replaces real bean in context OrderRepository repo; @Test void getOrders() throws Exception { when(repo.findAll()).thenReturn(List.of(new Order("O-1", Status.PENDING))); mockMvc.perform(get("/orders")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value("O-1")); } } // Dependency versions for JUnit 6 compatibility (mid-2026): // Mockito 5.x: compatible with JUnit 6 // Spring Boot 3.x: compatible with JUnit 6 // spring-boot-starter-test auto-includes junit-jupiter
41. What is the TestExecutionListener SPI in JUnit 6?
The TestExecutionListener is a Service Provider Interface (SPI) that allows external tools (IDEs, build plugins, reporting frameworks) to observe test execution events without modifying test code. Unlike extensions (which are registered in test code), listeners are registered via ServiceLoader or programmatically through the launcher.
// Implement a custom TestExecutionListener: public class JsonReportListener implements TestExecutionListener { private final List<TestResult> results = new ArrayList<>(); @Override public void testStarted(TestIdentifier id) { System.out.println("Starting: " + id.getDisplayName()); } @Override public void executionFinished( TestIdentifier id, TestExecutionResult result) { results.add(new TestResult( id.getDisplayName(), result.getStatus() )); } @Override public void testPlanExecutionFinished(TestPlan plan) { // Write JSON report after all tests complete writeJsonReport(results); } } // Register via ServiceLoader (META-INF/services): // File: src/test/resources/META-INF/services/ // org.junit.platform.launcher.TestExecutionListener // Content: // com.example.JsonReportListener // Or register programmatically via LauncherDiscoveryRequest: LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder .request() .selectors(selectPackage("com.example")) .build(); Launcher launcher = LauncherFactory.create(); launcher.discover(request); launcher.execute(request, new JsonReportListener());
42. What are the key differences between JUnit 4 and JUnit 6?
JUnit 4 and JUnit 6 have fundamentally different architectures, annotation models, and extension mechanisms. This is the most common comparison question in interviews where teams have legacy JUnit 4 code.
| Aspect | JUnit 4 | JUnit 6 |
|---|---|---|
| Package | org.junit | org.junit.jupiter.api |
| Test annotation | @Test (org.junit) | @Test (org.junit.jupiter.api) |
| Setup | @Before / @BeforeClass | @BeforeEach / @BeforeAll |
| Teardown | @After / @AfterClass | @AfterEach / @AfterAll |
| Ignore | @Ignore | @Disabled |
| Categorise | @Category | @Tag |
| Expected exception | @Test(expected=Ex.class) | assertThrows() |
| Timeout | @Test(timeout=1000) | assertTimeout(Duration.ofSeconds(1), ...) |
| Extension mechanism | @RunWith + @Rule + @ClassRule | @ExtendWith (single composable model) |
| Runners | @RunWith(MockitoJUnitRunner.class) | @ExtendWith(MockitoExtension.class) |
| Parameterised | @RunWith(Parameterized.class) | @ParameterizedTest (inline, no runner needed) |
| Assert style | Assert.assertEquals() | Assertions.assertEquals() or AssertJ |
| Minimum Java | Java 5 | Java 17 (JUnit 6) |
43. What is TestWatcher extension interface in JUnit 6 and when do you use it?
The TestWatcher interface provides callbacks for the four possible test outcomes: succeeded, failed, aborted, and disabled. It is simpler than implementing multiple separate extension interfaces when you just want to react to test results.
import org.junit.jupiter.api.extension.TestWatcher; public class SlackNotificationWatcher implements TestWatcher { @Override public void testSuccessful(ExtensionContext ctx) { // Optional: log to metrics dashboard } @Override public void testFailed(ExtensionContext ctx, Throwable cause) { // Alert the team on test failure String testName = ctx.getDisplayName(); String error = cause.getMessage(); slackClient.sendAlert( String.format("Test FAILED: %s%nError: %s", testName, error) ); } @Override public void testAborted(ExtensionContext ctx, Throwable cause) { // Log skipped tests (assumption violated) } @Override public void testDisabled(ExtensionContext ctx, Optional<String> reason) { // Track @Disabled tests for tech debt reporting techDebtTracker.record(ctx.getDisplayName(), reason.orElse("No reason given")); } } // Register: @ExtendWith(SlackNotificationWatcher.class) class CriticalPathTest { @Test void paymentProcessing() { ... } @Test @Disabled("Awaiting payment gateway fix") void refundProcessing() { ... } }
44. What is the ConsoleLauncher and how do you run JUnit 6 tests from the command line?
The ConsoleLauncher (junit-platform-console-standalone) is a self-contained JAR that can discover and run JUnit 6 tests from the command line without Maven or Gradle. It is useful for CI scripts, Docker containers, and quick local runs.
# Download the standalone JAR: # From: https://repo1.maven.org/maven2/org/junit/platform/ # junit-platform-console-standalone/6.1.1/ # junit-platform-console-standalone-6.1.1.jar # Basic run: scan classpath for tests java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --classpath "target/test-classes:target/classes" # Select specific class: java -jar junit-platform-console-standalone-6.1.1.jar \ --select-class "com.example.OrderServiceTest" # Select by package: java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --select-package "com.example.unit" # Filter by tag: java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --include-tag "fast" \ --exclude-tag "slow" # Fail fast (JUnit 6 CancellationToken): java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --fail-fast # Generate XML report (for CI systems like Jenkins): java -jar junit-platform-console-standalone-6.1.1.jar \ --scan-class-path \ --reports-dir ./test-reports
45. What is the @AutoClose extension and how does it simplify resource management in JUnit 6?
The @AutoClose annotation (introduced in JUnit 5.11 and fully supported in JUnit 6) automatically calls close() on fields implementing AutoCloseable at the end of the test lifecycle, eliminating the need for @AfterEach/@AfterAll teardown methods for simple resource cleanup.
import org.junit.jupiter.api.AutoClose; class AutoCloseDemo { // Closed after each test method (instance-scoped) @AutoClose private final Connection db = DriverManager.getConnection(TEST_DB_URL); // Closed once after all tests (static = class-scoped) @AutoClose private static final HttpClient httpClient = HttpClient.newHttpClient(); @Test void queryUsers() throws SQLException { // db is open and ready; closed automatically after this test try (PreparedStatement ps = db.prepareStatement("SELECT * FROM users")) { ResultSet rs = ps.executeQuery(); assertTrue(rs.next()); } } @Test void callExternalApi() throws Exception { // httpClient is open; closed once after all tests complete HttpResponse<String> resp = httpClient.send( HttpRequest.newBuilder(URI.create("https://api.test.com")).build(), HttpResponse.BodyHandlers.ofString() ); assertEquals(200, resp.statusCode()); } } // No @AfterEach or @AfterAll needed for these resources! // Equivalent JUnit 5 code (before @AutoClose): class JUnit5Equivalent { private Connection db; @BeforeEach void setUp() throws SQLException { db = DriverManager.getConnection(TEST_DB_URL); } @AfterEach void tearDown() throws SQLException { if (db != null) db.close(); // <-- boilerplate eliminated by @AutoClose } }
46. How does JUnit 6 support for Kotlin differ from JUnit 5?
JUnit 6 significantly improves Kotlin support beyond just suspend function tests. The minimum Kotlin version is 2.2 and several common pain points from JUnit 5 Kotlin usage are addressed.
| Area | JUnit 5 pain point | JUnit 6 improvement |
|---|---|---|
| suspend tests | runBlocking required everywhere | Suspend functions work directly as @Test, @BeforeEach, @AfterEach |
| Nullability | API had no nullability contracts; !! needed | JSpecify annotations enable smart casts after assertNotNull |
| @BeforeAll in objects | Required @JvmStatic | @TestInstance(PER_CLASS) avoids @JvmStatic |
| kotlin-test interop | kotlin.test.assertEquals vs junit.jupiter assertions | JSpecify nullability aligns contracts between both |
| Minimum version | Kotlin 1.6+ | Kotlin 2.2+ required |
// Kotlin JUnit 6 test class import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* @TestInstance(TestInstance.Lifecycle.PER_CLASS) class KotlinOrderTest { private lateinit var service: OrderService // No @JvmStatic needed with PER_CLASS lifecycle @BeforeAll suspend fun setUpAll() { service = OrderService() service.connect() // suspend function -- works directly! } @BeforeEach suspend fun setUp() { service.reset() // suspend function } @Test suspend fun `new order has pending status`() { val order = service.create("item-1", 2) // suspend call assertEquals(Status.PENDING, order.status) } @Test suspend fun `invalid quantity throws`() { assertThrows<IllegalArgumentException> { service.create("item-1", -1) // suspend call } } // JSpecify: smart cast works after assertNotNull @Test fun `smart cast after assertNotNull`() { val result: String? = service.findUser("user-1") assertNotNull(result) // JUnit 6 exposes @NonNull contract println(result.length) // No !! needed -- smart cast works } @AfterAll suspend fun tearDownAll() { service.disconnect() // suspend function } }
47. What is the @DisplayNameGeneration annotation in JUnit 6?
@DisplayNameGeneration automatically generates human-readable display names for all test methods in a class without requiring a @DisplayName on each method. It transforms method names (which must follow identifier rules) into more readable strings.
import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; // Converts method names to sentences: // - Removes underscores // - Removes trailing parentheses // "it_should_return_empty_when_null()" -> "it should return empty when null" @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class OrderServiceDisplayTest { @Test void new_order_has_pending_status() { ... } // Displayed as: "new order has pending status" @Test void order_with_invalid_quantity_throws_exception() { ... } // Displayed as: "order with invalid quantity throws exception" } // IndicativeSentences: generates "ClassName, method name" @DisplayNameGeneration(DisplayNameGenerator.IndicativeSentences.class) class PaymentTest { @Test void processValidPayment() { ... } // Displayed as: "PaymentTest, processValidPayment()" } // Custom generator: public class CamelCaseToSentenceGenerator implements DisplayNameGenerator { @Override public String generateDisplayNameForClass(Class<?> cls) { return splitCamelCase(cls.getSimpleName()); } @Override public String generateDisplayNameForNestedClass(Class<?> cls) { return splitCamelCase(cls.getSimpleName()); } @Override public String generateDisplayNameForMethod(Class<?> cls, Method method) { return splitCamelCase(method.getName()); } private String splitCamelCase(String name) { return name.replaceAll("([A-Z])", " $1").trim().toLowerCase(); } } // Set globally in junit-platform.properties: // junit.jupiter.displayname.generator.default= // org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
48. What is TestReporter in JUnit 6 and how do you use it?
TestReporter lets test methods publish key-value entries to the test execution report. Unlike System.out.println, reported values are attached to the test result and appear in IDE test reports and XML output -- they are not just lost in console noise.
import org.junit.jupiter.api.TestReporter; class TestReporterDemo { @Test void reportingDemo(TestReporter reporter) { // Publish a single key-value entry reporter.publishEntry("user-id", "U-12345"); reporter.publishEntry("status", "active"); // Publish a map of entries in one call Map<String, String> values = Map.of( "order-id", "O-99", "total", "149.99", "currency", "GBP" ); reporter.publishEntry(values); // Assertions still run after publishing entries assertTrue(true); } @Test void reportWithMessage(TestReporter reporter) { // Single-argument form: just a message string reporter.publishEntry("Processing payment for order O-42"); } } // TestReporter vs System.out: // // System.out.println: // - Goes to process stdout // - Not associated with any specific test in the report // - Lost in CI log noise // // TestReporter.publishEntry: // - Attached to the specific test result // - Appears in IDE test runner output alongside the test // - Included in XML/HTML test reports // - Visible when you click on a specific test result in the IDE
49. What are the unified version changes in JUnit 6 and why do they matter for dependency management?
One of the practical improvements in JUnit 6 is the adoption of a single unified version number across all three components. In JUnit 5, the components used different version schemes, causing widespread confusion and dependency conflicts.
| Component | JUnit 5 versions | JUnit 6 versions |
|---|---|---|
| JUnit Platform | 1.0.x through 1.11.x | 6.0.x through 6.x.x (same as others) |
| JUnit Jupiter | 5.0.x through 5.11.x | 6.0.x through 6.x.x (same as Platform) |
| JUnit Vintage | 5.0.x through 5.11.x | 6.0.x through 6.x.x (same as Platform) |
<!-- JUnit 5: confusing mixed versions --> <!-- Correct combination for JUnit 5.11.4: --> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-engine</artifactId> <version>1.11.4</version> <!-- Platform uses 1.x.x --> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.11.4</version> <!-- Jupiter uses 5.x.x --> </dependency> <!-- Common mistake: using 5.11.4 for Platform (wrong!) --> <!-- or 1.11.4 for Jupiter (also wrong!) --> <!-- JUnit 6: all use the same version --> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-engine</artifactId> <version>6.1.1</version> <!-- same version --> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>6.1.1</version> <!-- same version --> </dependency> <!-- BOM still recommended to avoid managing versions at all --> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>6.1.1</version> <type>pom</type><scope>import</scope> </dependency>
50. What are best practices and anti-patterns in JUnit 6 test design?
Writing maintainable, fast, and reliable tests requires following established principles. Knowing common anti-patterns is as important as knowing the APIs.
| Category | Best practice | Anti-pattern to avoid |
|---|---|---|
| Test isolation | Each test should set up its own state independently | Relying on test execution order or shared mutable state between tests |
| Assertion messages | Use lazy message suppliers: assertEquals(a, b, () -> 'msg') | Eager string concatenation in message: assertEquals(a, b, 'msg: ' + val) -- always evaluated |
| Exception testing | Use assertThrows() and verify the message | @Test(expected=) which doesn't let you inspect the exception |
| Grouping | Use assertAll() for multi-field object validation | Multiple separate assertions -- first failure hides others |
| Naming | Use @DisplayName or @DisplayNameGeneration for readable names | cryptic method names like testMethod1() |
| Extension reuse | Extract cross-cutting concerns into extensions or shared flows | Copy-pasting @BeforeEach setup across test classes |
| Parallel safety | Use @ResourceLock for shared resources | Shared mutable static state without synchronisation |
| Temporary files | Use @TempDir(cleanup=ON_SUCCESS) for debuggable failures | Manual file management in @BeforeEach/@AfterEach |
// ANTI-PATTERN: order-dependent tests @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class BadTest { static String sharedState; @Test @Order(1) void setup() { sharedState = "ready"; } // BAD @Test @Order(2) void useIt() { assertEquals("ready", sharedState); } } // BEST PRACTICE: independent setup per test class GoodTest { @BeforeEach void init() { this.state = buildTestState(); } // each test gets fresh state @Test void test1() { assertEquals("ready", state.status()); } @Test void test2() { assertEquals("ready", state.status()); } // also passes } // ANTI-PATTERN: testing multiple concerns in one test @Test void testEverything() { // Tests create, update, delete, and serialise all at once // When it fails, you don't know WHAT broke } // BEST PRACTICE: one concern per test @Test void createOrder_succeeds() { ... } @Test void updateOrder_changesStatus() { ... } @Test void deleteOrder_removesFromStore() { ... }
