JUnit 6 Deep Dive: What’s New, What Changed, and How to Migrate

Eight years after JUnit 5 shipped, the framework has a new major version. JUnit 6.0.0 released on September 30, 2025 — and unlike the painful JUnit 4 → 5 migration that rewrote the annotation model from scratch, this one is designed to be a calm, deliberate step forward. The current stable release is 6.1.1 (June 28, 2026).

JUnit 6 is not a revolution. It’s a maturation: a Java 17 baseline, unified versioning, first-class Kotlin coroutine support, null-safety annotations across the entire API, and a proper cancellation model for CI pipelines. If you’re already on JUnit 5.14 and Java 17+, the migration is mostly a version bump in your pom.xml. This guide covers what changed, what broke, and what the upgrade looks like in practice.

What Changed at a Glance

Feature / ChangeJUnit 5JUnit 6
Java baselineJava 8+Java 17+
Kotlin baselineKotlin 1.xKotlin 2.2+
VersioningPlatform 1.x / Jupiter 5.xUnified 6.x across all modules
Kotlin coroutines in testsManual runBlocking / runTestNative suspend support
CSV parsing engineunivocity-parsers (unmaintained)FastCSV (RFC 4180 compliant)
Null safetyDocumentation-onlyJSpecify annotations throughout
JFR supportSeparate junit-platform-jfr artifactBuilt into junit-platform-launcher
@Nested class orderingNon-deterministicDeterministic by default
Cancellation APINot availableCancellationToken API
JUnit VintageSupportedDeprecated

1. Java 17 and Kotlin 2.2 as the New Baseline

The most consequential change is also the most visible one: JUnit 6 requires Java 17 as a minimum. This is a deliberate choice over Java 21 — Java 17 is the LTS that most enterprise projects have actually migrated to, aligned with Spring Boot 3.x, Micronaut 4.x, and AssertJ 4.x. A Java 21 requirement would have blocked more real-world teams than it helped.

For Kotlin users, Kotlin 2.2 is the new minimum. This is necessary for the native suspend support (covered below) and allows the JUnit Jupiter engine to use Kotlin-specific APIs that weren’t stable in 1.x.

Internally, the Java 17 baseline lets the JUnit team use records for result types, sealed classes in the extension model hierarchy, and text blocks in parsing code — none of which were possible while supporting Java 8. You won’t see these changes directly in your test code, but they make the framework faster and easier for the team to maintain.

If you’re on Java 8 or 11: JUnit 5 continues to receive maintenance updates. The 5.14.x branch is the current LTS for those environments.


2. Unified Versioning — One Version Number for Everything

JUnit 5’s split versioning (Platform 1.x, Jupiter 5.x) broke the semantic versioning contract and confused newcomers consistently. JUnit 6 fixes this: all modules — Platform, Jupiter, and Vintage — share the same version number. The current stable is 6.1.1.

Your Maven setup simplifies to:

<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>

No more tracking junit-platform at 1.x and junit-jupiter at 5.x separately. The BOM pins everything consistently.

Important for build plugins: Maven Surefire and Failsafe versions older than 3.0.0 are no longer supported. Upgrade to Surefire 3.x if you haven’t already.


3. Native Kotlin suspend Support

This is the feature Kotlin developers have wanted since JUnit 5 shipped. Previously, testing coroutines required wrapping every test body in runBlocking or runTest — wrapper functions that didn’t belong in test method signatures.

Before (JUnit 5):

class OrderServiceTest {
    private val service = OrderService()

    @BeforeEach
    fun setup() = runBlocking {
        service.initializeDatabase()
    }

    @Test
    fun `should return pending orders`() = runTest {
        val orders = service.fetchPending()
        assertEquals(3, orders.size)
    }
}

After (JUnit 6):

class OrderServiceTest {
    private val service = OrderService()

    @BeforeEach
    suspend fun setup() {
        service.initializeDatabase() // direct suspend call, no wrapper
    }

    @Test
    suspend fun `should return pending orders`() {
        val orders = service.fetchPending()
        assertEquals(3, orders.size)
    }
}

The JUnit Jupiter engine now manages the coroutine context internally. @BeforeEach, @AfterEach, @BeforeAll, @AfterAll, and @Test methods can all be suspend functions. The result: asynchronous test code reads like production code, without any ceremony.


4. FastCSV for @CsvSource and @CsvFileSource

JUnit 6 replaces the unmaintained univocity-parsers library with FastCSV — RFC 4180 compliant, actively maintained, zero additional dependencies, and noticeably faster for large @CsvFileSource datasets.

For most tests the behavior is identical, but there are a few breaking changes to watch for:

  • lineSeparator attribute in @CsvFileSource has been removed. Line endings (r, n, rn) are now auto-detected. Remove any lineSeparator attribute from your annotations.
  • Extra characters after a closing quote now throw an exception. For example, "value"extra was silently tolerated before; now it’s a parse error. Check for malformed CSV in your test data files.
  • Default comment character is #. If your CSV data contains literal # as a delimiter, use the new commentCharacter attribute to change it (added in 6.0.1 to fix a regression).

The text block syntax with column headers and inline comments continues to work as it did in JUnit 5.8+:

@ParameterizedTest(name = "user {USER_ID} with role {ROLE}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
    USER_ID, ROLE,   CAN_PUBLISH
    // Elevated roles
    1001,    EDITOR, true
    1002,    ADMIN,  true
    // Restricted
    1003,    USER,   false
    """)
void testPublishingPermissions(int userId, String role, boolean canPublish) {
    assertEquals(canPublish, cms.canPublish(userId, role));
}

5. Full Null Safety via JSpecify

Every method parameter, return type, and field in JUnit 6’s public API is now annotated with JSpecify annotations: @Nullable, @NonNull, and @NullMarked. The @NullMarked annotation is applied at the package or class level, making unannotated types non-null by default — so only the exceptions (@Nullable) need to be explicitly marked.

@NullMarked
class ExampleTest {

    // Return type is explicitly nullable
    @Nullable
    String getNullableResult(boolean returnNull) {
        return returnNull ? null : "expected value";
    }

    @Test
    void nullableResultHandledCorrectly() {
        String result = getNullableResult(false);
        assertEquals("expected value", result);
    }
}

The practical impact:

  • Kotlin users: The Kotlin compiler recognizes JSpecify annotations. Passing null to a @NonNull JUnit method is now a compile-time error rather than a runtime NullPointerException.
  • Java users with NullAway or ErrorProne: Both tools understand JSpecify annotations. Null-safety violations in your test code are caught at build time.
  • IDE support: IntelliJ 2024.3+ highlights nullable mismatches in test code using these annotations.

6. CancellationToken API and Fail-Fast Execution

Long integration test suites running in CI had no clean cancellation mechanism in JUnit 5 — build tools had to forcibly kill the process, skipping teardown and leaving resources in an inconsistent state.

JUnit 6 introduces a CancellationToken API. Launchers (IntelliJ, Gradle, GitHub Actions) can send a cooperative cancellation signal to the TestEngine, which propagates it through the running suite. Tests and extensions can check for cancellation between steps and abort cleanly — closing database connections, releasing locks, completing teardown.

The --fail-fast mode in ConsoleLauncher builds on this: the first test failure triggers a CancellationToken that cancels the remaining test run. For large suites with slow integration tests, this saves meaningful compute time on every failed PR build.

# Run tests, stop on first failure
java -jar junit-platform-console-standalone-6.1.1.jar 
  --scan-class-path 
  --fail-fast

For Gradle/Maven, fail-fast support depends on plugin versions — check your Surefire 3.x or Gradle JUnit Platform plugin documentation for the equivalent configuration.


7. Deterministic @Nested Class Ordering

In JUnit 5, the execution order of @Nested test classes was non-deterministic — it depended on whatever order the JVM reflection APIs returned the declared classes, which varied between JDK versions and platforms. This made test reports inconsistent across different environments.

JUnit 6 fixes this with deterministic ordering of @Nested classes by default, plus two new orderers:

  • MethodOrderer.Default — reproducible default ordering for test methods within a class.
  • ClassOrderer.Default — reproducible ordering for nested test classes.

Additionally, @TestMethodOrder is now inherited by enclosed @Nested classes, so you don’t need to repeat the annotation on every nested class when you want a consistent ordering strategy throughout.

@TestMethodOrder(MethodOrderer.OrderAnnotation.class) // inherited by nested classes
class PaymentFlowTest {

    @Nested
    @Order(1)
    class ValidationTests {
        @Test @Order(1) void shouldRejectNegativeAmount() { ... }
        @Test @Order(2) void shouldRejectExpiredCard() { ... }
    }

    @Nested
    @Order(2)
    class ProcessingTests {
        @Test @Order(1) void shouldChargeCorrectAmount() { ... }
        @Test @Order(2) void shouldSendConfirmationEmail() { ... }
    }
}

8. JFR Built Into the Launcher

The separate junit-platform-jfr artifact has been removed. Java Flight Recorder event support is now built directly into junit-platform-launcher under the org.junit.platform.launcher.jfr package.

Start a JFR recording when running tests, then inspect test discovery and execution events in JDK Mission Control — without adding any extra dependency. This is particularly useful for identifying slow test setup phases, understanding @BeforeAll overhead, and profiling test infrastructure in large suites.


9. Breaking Changes and Removals

JUnit 6 removes APIs that have been deprecated in 5.x for multiple release cycles. Before upgrading, search your codebase for these:

What’s RemovedWhat to Use Instead
junit-platform-runner module (JUnit 4 runner)Use native IDE/build tool integration or JUnit Vintage
junit-platform-jfr moduleJFR now built into junit-platform-launcher
MethodOrderer.AlphanumericMethodOrderer.Default or MethodOrderer.OrderAnnotation
ReflectionUtils.readFieldValue()Standard Java reflection APIs
ReflectionSupport.loadClass()Standard Class.forName()
@CsvFileSource lineSeparator attributeAuto-detected; remove the attribute
ConsoleLauncher without subcommandUse execute subcommand explicitly
Non-conventional flags like --h, -helpUse -h / --help

JUnit Vintage: Still present in 6.0.x but now formally deprecated. It emits INFO-level warnings at runtime when JUnit 4 tests are discovered. It will be removed in a future major version — don’t use this window to delay JUnit 4 → 6 migration.

junit-jupiter-migrationsupport: Deprecated in 6.0.0, also slated for removal in the next major version.


Migrating from JUnit 5 to JUnit 6

For teams already on JUnit 5.14 and Java 17+, the JUnit team describes this as a “routine dependency bump.” Here’s the practical order of operations:

  1. Update the BOM version to 6.1.1 in your pom.xml or build.gradle. Let the BOM resolve all module versions.
  2. Upgrade Maven Surefire/Failsafe to 3.x if you haven’t already — versions older than 3.0.0 are no longer supported.
  3. Run the build and review failures. Most failures will be in one of three categories: removed APIs, @CsvSource/@CsvFileSource parse errors from stricter FastCSV rules, or MethodOrderer.Alphanumeric references.
  4. Fix @CsvFileSource issues: Remove lineSeparator attributes. Fix any malformed CSV (extra characters after closing quotes). If your data contains #, set commentCharacter = '' or another unused character.
  5. Replace removed APIs using the table above.
  6. Migrate JUnit 4 tests now — don’t rely on Vintage being available in 7.x. IntelliJ’s built-in JUnit 4 → Jupiter migration handles the mechanical annotation changes automatically.
  7. For Kotlin users: Update to Kotlin 2.2 first, then migrate runBlocking/runTest wrappers to suspend test methods at your own pace.

The JUnit team maintains a migration wiki on GitHub with the authoritative list of changes and upgrade paths.


Frequently Asked Questions

Should I upgrade now?

If your project is on Java 17+ and JUnit 5.14, upgrade to 6.1.1 now. The 6.x line is stable — 6.0.3 addressed a deadlock in NamespacedHierarchicalStore and a @EnabledOnJre/@DisabledOnJre reliability issue, and 6.1.x (GA since May 2026) added improved stack trace pruning and new extensions for system properties and locale/timezone management. There’s no reason to wait.

Will my JUnit 5 tests run on JUnit 6 without changes?

Mostly yes, with some exceptions. The annotation model (all the @Test, @BeforeEach, @ParameterizedTest, extension annotations) is unchanged. What breaks is use of removed APIs, malformed CSV data that was previously tolerated, and MethodOrderer.Alphanumeric. Run your build with JUnit 6.1.1 and let the compiler and test failures show you exactly what needs fixing.

Can I mix JUnit 5 and JUnit 6 in the same project?

Not easily. The Platform, Jupiter, and Vintage artifacts are all versioned together in 6.x, so you’d need separate module classpaths to run both engines simultaneously. The practical approach is to migrate fully. If you have JUnit 4 tests, use JUnit Vintage (6.x, deprecated) as a temporary bridge rather than trying to keep JUnit 5 in parallel.

How does JUnit 6 affect Spring Boot testing?

Spring Boot 3.4+ is compatible with JUnit 6. @SpringBootTest, @DataJpaTest, @WebMvcTest, and all the Spring test slices work without changes. The @ExtendWith(SpringExtension.class) registration through @SpringBootTest continues to work via the JUnit 6 extension model, which is unchanged from JUnit 5.


See Also


Final Verdict: Upgrade or Wait?

If you’re on Java 17+ and JUnit 5.14: upgrade now. This is the least painful major framework migration the JUnit team has produced. The annotation model is untouched, the test structure is the same, and the effort is concentrated in a handful of removed APIs and stricter CSV parsing. Most teams with reasonable test hygiene will clear the migration in a few hours.

If you’re on Java 8 or 11: stay on JUnit 5.14.x and plan your Java upgrade first. The JUnit 6 benefits are real — null safety, native coroutine support, unified versioning — but none of them are worth a Java version upgrade you’re not ready for.

The framework has found its shape. JUnit 6 is not a revolution; it’s eight years of lessons from JUnit 5 turned into a cleaner, more modern foundation for the next decade of Java testing.

Leave a Reply

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