JUnit 6 vs JUnit 5: Key Differences, Features, and Migration Guide

If you have been using JUnit 5 (also known as JUnit Jupiter) and are evaluating whether to move to JUnit 6, or you just want to understand what changed and why, this guide has every answer. We compare the two frameworks side by side β€” architecture, annotations, APIs, extension model, and migration path β€” with real code examples throughout.

The short answer: JUnit 6 is evolutionary, not revolutionary. If you know JUnit 5, you already know 90% of JUnit 6. The upgrade pays off in cleaner extension APIs, improved parameterized tests, and better alignment with modern Java features like records and sealed classes.

Updated for Spring Boot 4 (2026): With the release of Spring Boot 4.0 on 20 November 2025, this migration is no longer optional for many teams. Spring Boot 4 builds on Spring Framework 7 and makes JUnit 6 the default testing baseline β€” JUnit 4 support is deprecated and the Vintage engine now serves only as a temporary bridge. If you are planning a Spring Boot 4 upgrade, JUnit 6 comes with it. For the wider picture, see our Spring Boot 3 to 4 Migration Guide and Spring Framework 6 to 7 Migration Guide.

TL;DR: JUnit 5 vs JUnit 6 at a Glance

If you only need the headline differences before deciding, this table sums up the move from JUnit 5 to JUnit 6:

AspectJUnit 5 (Jupiter)JUnit 6 (Jupiter)
Java baselineJava 8Java 17
Base packageorg.junit.jupiter.*org.junit.jupiter.* (unchanged)
Core annotations & assertionsFull setIdentical β€” no rename
Null-safety annotationsNoneJSpecify (@Nullable, etc.)
Extension modelStableRefined, deterministic ordering
Parameterized sourcesRichRicher type & record conversion
@Nested supportYesEnhanced
JUnit 4 via VintageSupportedDeprecated (temporary bridge)
Migration effortβ€”Drop-in for most projects (version bump)
Spring Boot2.7 – 3.x4.0+ (default)

Architecture: What Stayed the Same

Both JUnit 5 and JUnit 6 are built on the same three-pillar architecture:

ModulePurposeJUnit 5JUnit 6
PlatformLaunches test frameworks on the JVMβœ… Presentβœ… Present (refined)
JupiterProgramming model for writing testsβœ… Presentβœ… Present (enhanced)
VintageRuns JUnit 3/4 tests on the Platformβœ… Present⚠️ Present but deprecated

Side-by-Side Annotation Comparison

All core annotations are identical in name and purpose. JUnit 6 adds refinements, not replacements:

FeatureJUnit 5JUnit 6Change
Test method@Test@TestSame
Before each test@BeforeEach@BeforeEachSame
After each test@AfterEach@AfterEachSame
Before all tests@BeforeAll@BeforeAllSame
After all tests@AfterAll@AfterAllSame
Display name@DisplayName@DisplayNameSame
Nested tests@Nested@NestedEnhanced
Tag@Tag@TagSame
Disable test@Disabled@DisabledSame
Parameterized@ParameterizedTest@ParameterizedTestEnhanced sources
Dynamic tests@TestFactory@TestFactorySame
Extensions@ExtendWith@ExtendWithEnhanced API
Timeout@Timeout@TimeoutSame
Temp directory@TempDir@TempDirSame
Method ordering@TestMethodOrder@TestMethodOrderMore strategies

Key Differences: What Actually Changed in JUnit 6

1. Enhanced Extension Model

JUnit 5’s extension model was already powerful, but JUnit 6 refines it with a cleaner callback order, better support for extension composition, and new extension points for test suite lifecycle events.

// JUnit 5: Extension interface, works but callback ordering could be ambiguous
public class TimingExtension implements BeforeEachCallback, AfterEachCallback {
    private long startTime;

    @Override
    public void beforeEach(ExtensionContext context) {
        startTime = System.currentTimeMillis();
    }

    @Override
    public void afterEach(ExtensionContext context) {
        long duration = System.currentTimeMillis() - startTime;
        System.out.println(context.getDisplayName() + " took " + duration + "ms");
    }
}

// JUnit 6: Same interface, but execution order with composed extensions
// is now deterministic and well-specified in the documentation
@ExtendWith(TimingExtension.class)
class OrderServiceTest {
    @Test
    void processingAnOrderCompletesWithinOneSecond() {
        // TimingExtension automatically logs duration
    }
}

2. Improved Parameterized Test Sources

JUnit 6 adds new argument sources for @ParameterizedTest and improves conversion of complex types. JUnit 5 required custom converters for many scenarios; JUnit 6 handles more cases automatically:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class StringUtilsTest {

    // JUnit 5 + 6: @ValueSource works the same
    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "junit"})
    void stringIsNotBlank(String input) {
        assertFalse(input.isBlank());
    }

    // JUnit 5 + 6: @CsvSource works the same
    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource({
        "1, 2, 3",
        "10, 20, 30",
        "-5, 5, 0"
    })
    void additionProducesCorrectResult(int firstNumber, int secondNumber, int expectedSum) {
        assertEquals(expectedSum, firstNumber + secondNumber);
    }

    // JUnit 6: Enhanced support for Java Records as parameter sources
    record AdditionCase(int a, int b, int expected) {}

    // JUnit 6 can convert record types automatically in argument sources
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void allPositiveIntsAreGreaterThanZero(int number) {
        assertTrue(number > 0);
    }
}

3. Better Java Records and Sealed Classes Support

JUnit 6 was designed with modern Java (17+) in mind. It works seamlessly with Java records and sealed classes in test subjects, argument converters, and extension parameters:

// Production code: Java Record (Java 16+)
public record Point(int x, int y) {
    public double distanceTo(Point other) {
        int dx = this.x - other.x;
        int dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}

// JUnit 6 test: works naturally with records
class PointTest {

    @Test
    void distanceBetweenOriginAndPointOnXAxisEqualsX() {
        Point origin = new Point(0, 0);
        Point pointOnXAxis = new Point(5, 0);

        double distance = origin.distanceTo(pointOnXAxis);

        assertEquals(5.0, distance, 0.001, "Distance should be 5.0");
    }

    @Test
    void twoRecordsWithSameValueAreEqual() {
        // Record equality is value-based β€” JUnit assertions work correctly
        Point first  = new Point(3, 4);
        Point second = new Point(3, 4);
        assertEquals(first, second);
    }
}

4. Refined Lifecycle Semantics

JUnit 6 clarifies and extends @TestInstance(Lifecycle.PER_CLASS) behaviour β€” when a single instance is shared across all test methods, @BeforeAll and @AfterAll no longer need to be static:

import org.junit.jupiter.api.*;

// PER_CLASS lifecycle: one test instance shared across all methods
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DatabaseIntegrationTest {

    private Connection databaseConnection;

    // NOT static β€” allowed with PER_CLASS lifecycle
    @BeforeAll
    void openDatabaseConnection() {
        databaseConnection = DriverManager.getConnection("jdbc:h2:mem:test");
        System.out.println("Database connection opened");
    }

    @Test
    void queryingUsersTableReturnsNonNullResult() {
        assertNotNull(databaseConnection);
    }

    // NOT static β€” allowed with PER_CLASS lifecycle
    @AfterAll
    void closeDatabaseConnection() throws Exception {
        databaseConnection.close();
        System.out.println("Database connection closed");
    }
}

5. JSpecify Null-Safety Annotations

Starting with version 6.0, JUnit adopts JSpecify for null-safety annotations across its public API, improving compile-time type safety and IDE support. This aligns JUnit 6 with the same JSpecify null-safety model adopted by Spring Framework 7, so nullability hints flow consistently from your production code through to your tests.

Removed and Deprecated APIs in JUnit 6

This is the part most teams worry about β€” and it is smaller than you expect. JUnit 6.0 only removed APIs that had been deprecated for more than two years, which is why it behaves as a drop-in replacement for the vast majority of suites. Here is what actually changed:

AreaWhat changed in JUnit 6What to do
Java baselineJava 8–16 support dropped; Java 17 is now the minimumUpgrade your JDK to 17+ (the single most common blocker)
Long-deprecated APIsMethods/overloads deprecated for 2+ years across junit-jupiter and junit-platform are removedRecompile and fix the handful of warnings/errors the compiler surfaces
Vintage engineDeprecated; logs an INFO discovery issue when it finds JUnit 4 testsKeep only as a temporary bridge while migrating to Jupiter
Spring JUnit 4 supportSpring Framework 7 deprecates SpringRunner / @RunWith supportSwitch to @ExtendWith(SpringExtension.class) or @SpringBootTest
Build toolingNewer minimum versions of Maven Surefire/Failsafe and Gradle are requiredBump your build plugins (recent Surefire 3.x / Gradle 8.x)

The fastest way to find your specific removals is to bump the version, run a compile, and read the deprecation/removal warnings β€” then cross-check anything unfamiliar against the official JUnit 6.0 release notes. In a typical application that targets Java 17+ already, this step produces zero or a small handful of changes.

JUnit 6 and Spring Boot 4: What the Upgrade Forces

JUnit 6 reached General Availability in September 2025 and is the testing foundation for Spring Boot 4.0 (released 20 November 2025) and Spring Framework 7. If you upgrade to Spring Boot 4, you adopt JUnit 6 by default β€” its BOM manages the JUnit 6 version for you. Here is what changes on the testing side.

Spring Boot ↔ Spring Framework ↔ JUnit Compatibility

Use this matrix to confirm which JUnit line your Spring Boot version manages. The key takeaway: JUnit 6 arrives with Spring Boot 4.0 β€” earlier Spring Boot lines stay on JUnit 5.

Spring BootSpring FrameworkManaged JUnitJava baseline
2.7.x5.3.xJUnit 5.8 (Vintage available)Java 8+
3.0 – 3.36.0 – 6.1JUnit 5.9 – 5.10Java 17+
3.4 – 3.56.2JUnit 5.11Java 17+
4.07.0JUnit 6.0Java 17+ (21/25 recommended)

Because the Spring Boot BOM pins these versions, the safest approach is to let the BOM decide and avoid hard-coding a JUnit version that conflicts with your Spring Boot line.

JUnit 4 Support Is Deprecated

JUnit 4 is now officially in maintenance mode, and Spring Framework 7.0 deprecates its JUnit 4 support in favour of SpringExtension and JUnit Jupiter. If you still use SpringRunner / @RunWith(SpringRunner.class), you will see a deprecation warning steering you to the modern @ExtendWith(SpringExtension.class) model (which @SpringBootTest and the slice annotations already apply for you).

The JUnit Vintage engine β€” which runs legacy JUnit 4 tests on the JUnit Platform β€” is also deprecated. It now reports an INFO-level discovery issue when it encounters JUnit 4 test classes, signalling that it should only be used temporarily while you finish migrating to JUnit Jupiter. For a step-by-step JUnit 4 β†’ 6 path, see our Refactoring Legacy Tests to JUnit 6 (Migration Playbook).

Test Starters Are Now Modular

Spring Boot 4 splits the once-monolithic spring-boot-starter-test into smaller, focused test starters that mirror the new modular production starters. Each technology starter now has a matching test companion β€” for example, the web stack moves from spring-boot-starter-web to spring-boot-starter-webmvc, with spring-boot-starter-webmvc-test and spring-boot-starter-restclient-test on the test side:

<!-- Maven (Spring Boot 4): modularized test starters -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-restclient-test</artifactId>
    <scope>test</scope>
</dependency>
// Gradle (Spring Boot 4): same modular test starters
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.boot:spring-boot-starter-restclient-test'

If you want a gentler transition, Spring Boot 4 also ships β€œclassic” starter modules that mimic the old non-modular layout, letting you migrate one module at a time.

SpringExtension Context Scope Change (the @Nested Trap)

In Spring Framework 7, SpringExtension now uses a test-method-scoped ExtensionContext. This gives consistent dependency-injection semantics inside @Nested hierarchies, but it can break custom TestExecutionListener implementations. If Spring integration tests in @Nested classes start failing after the upgrade, restore the old behaviour on the top-level class:

// Escape hatch if @Nested Spring tests break after upgrading to Spring Framework 7
@SpringExtensionConfig(useTestClassScopedExtensionContext = true)
@SpringBootTest
class OrderModuleTest {

    @Nested
    class WhenOrderIsValid {
        // ... inherited Spring context now behaves as before
    }
}

If you have custom listeners that override prepareTestInstance(TestContext) and call testContext.getTestClass(), switch to testContext.getTestInstance().getClass().

Bundled Testing Library Versions

Spring Boot 4 also refreshes the rest of the test toolchain through its BOM, so most teams inherit these without touching version numbers:

  • Mockito 5.20 β€” improved mocking, inline mock maker by default
  • AssertJ 3.27.6
  • Awaitility 4.3.0, Hamcrest 3.0, HtmlUnit 4.17, Selenium 4.37
  • Testcontainers 2.0 β€” note the breaking module rename: artifacts now use the testcontainers- prefix (e.g. testcontainers-postgresql instead of postgresql), and JUnit 4 support is removed entirely

A quieter but welcome change: Spring Framework 7 allows @MockitoBean, @MockitoSpyBean, and @TestBean to override non-singleton beans (prototype and custom scopes), which previously threw an IllegalStateException.

Migration Guide: JUnit 5 to JUnit 6

Migrating from JUnit 5 to JUnit 6 is straightforward because the API surface is largely identical β€” only APIs deprecated for more than two years were removed, making JUnit 6.0 a drop-in replacement in most projects. Follow these steps.

Step 1: Update the Dependency Version (Maven and Gradle)

If you are on Spring Boot 4, you can skip the explicit version β€” the Spring Boot BOM already manages JUnit 6. You only pin a version manually when you are still on Spring Boot 3.x and want to adopt JUnit 6 ahead of the framework.

<!-- Maven -->

<!-- Before: JUnit 5 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

<!-- After: JUnit 6 (omit <version> entirely on Spring Boot 4) -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.1</version>
    <scope>test</scope>
</dependency>
// Gradle (build.gradle)

// Before: JUnit 5
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'

// After: JUnit 6 (drop the version on Spring Boot 4 β€” the BOM manages it)
testImplementation 'org.junit.jupiter:junit-jupiter:6.1.1'

// Required to run tests on the JUnit Platform
test {
    useJUnitPlatform()
}

Step 2: Review Removed and Deprecated APIs

Run a compile after the version bump. JUnit 6 flags deprecated JUnit 5 APIs with compiler warnings, and removed APIs with errors. The most common items to update:

  • Any custom TestEngine implementations should be updated to use the refined SPI interfaces
  • Extension ordering in @ExtendWith chains should be reviewed for deterministic behaviour
  • Argument converters using internal APIs should migrate to public converter interfaces
  • Confirm your JDK is Java 17+ and your build plugins (Surefire/Failsafe, Gradle) meet the new minimums

Step 3: Migrate @RunWith to @ExtendWith

If your codebase still contains JUnit 4-style Spring tests β€” the ones using @RunWith(SpringRunner.class) β€” this is the change that matters most, because Spring Framework 7 deprecates that runner. JUnit Jupiter replaces runners with the extension model. Here is the direct before/after:

// BEFORE β€” JUnit 4 + Spring (deprecated in Spring Framework 7)
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import static org.junit.Assert.assertEquals;

@RunWith(SpringRunner.class)
@SpringBootTest
public class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Test
    public void totalIsCalculatedCorrectly() {
        assertEquals(30, orderService.total(10, 20));
    }
}
// AFTER β€” JUnit Jupiter (JUnit 5/6) + Spring
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;

// @SpringBootTest already registers SpringExtension β€” no @RunWith / @ExtendWith needed.
// (If you are NOT using a Spring Boot test annotation, add it explicitly:
//  @ExtendWith(SpringExtension.class) )
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Test
    void totalIsCalculatedCorrectly() {
        assertEquals(30, orderService.total(10, 20)); // note: (expected, actual)
    }
}

The mechanical mappings when moving runner-era tests to Jupiter:

JUnit 4 (runner era)JUnit Jupiter (5/6)
@RunWith(SpringRunner.class)@ExtendWith(SpringExtension.class) (implicit with @SpringBootTest)
@RunWith(MockitoJUnitRunner.class)@ExtendWith(MockitoExtension.class)
org.junit.Testorg.junit.jupiter.api.Test
@Before / @After@BeforeEach / @AfterEach
@BeforeClass / @AfterClass@BeforeAll / @AfterAll
@Ignore@Disabled
org.junit.Assert.*org.junit.jupiter.api.Assertions.*

Watch the assertion argument order: JUnit 4’s assertEquals(expected, actual) and the optional message position both differ from some other frameworks, but Jupiter keeps the same (expected, actual) order β€” only the message moves to the last parameter.

Step 4: Run and Verify

# Maven β€” run the full test suite and check nothing is broken
mvn test

# Gradle
./gradlew test

In the vast majority of projects, the test suite will pass with zero code changes β€” only the version number in pom.xml or build.gradle needs updating (and on Spring Boot 4, not even that). For larger codebases, the OpenRewrite Spring Boot 4 recipes can automate most of the mechanical changes, including the @RunWith β†’ @ExtendWith rewrites.

Performance: JUnit 6 vs JUnit 5

For a single test, the difference between JUnit 5 and JUnit 6 is negligible β€” you will not feel it. JUnit 6 trims internal engine overhead and reduces the cost of extension callbacks, so the gains only become measurable in suites with thousands of tests and heavy extension use. The real performance levers are the same in both versions:

  • Parallel execution is by far the biggest win β€” it is opt-in and configured identically in JUnit 5 and JUnit 6.
  • Context caching (for Spring tests) avoids restarting the application context between test classes.
  • Java 17+ baseline means JUnit 6 runs on a modern JIT and garbage collector, which helps large suites indirectly.

Enable parallel execution with a junit-platform.properties file on your test classpath (src/test/resources):

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent

# Tune the pool relative to available cores
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

If you are on Spring Boot 4, you also gain a structural improvement: Spring Framework 7’s test-context pausing automatically stops the background threads of cached-but-inactive application contexts (scheduled jobs, message listeners), so large integration suites consume less memory and avoid cross-context interference. This is a Spring-level change β€” not a JUnit one β€” but it is one of the most tangible test-time speedups you get by moving to the Spring Boot 4 + JUnit 6 stack.

When Should You Migrate?

  • βœ… Upgrading to Spring Boot 4 β€” you get JUnit 6 automatically; adopt it as part of the same upgrade
  • βœ… New project β€” always start with JUnit 6
  • βœ… Active JUnit 5 project on Spring Boot 3.x β€” migrate when you start a new sprint or feature; it is low risk, and it removes friction from your eventual Spring Boot 4 upgrade
  • βœ… Project using custom extensions β€” test the extension after upgrading; most work without changes, but review extension ordering and any custom TestExecutionListener
  • ⚠️ Project still relying on JUnit 4 / the Vintage engine β€” plan the JUnit 4 β†’ Jupiter migration now; Vintage is deprecated in JUnit 6 and Spring Framework 7 and is meant only as a temporary bridge

Frequently Asked Questions (FAQs)

Q1: Do I need to rewrite my JUnit 5 tests to use JUnit 6?

No. For the vast majority of tests, changing the version number is the only change needed β€” and on Spring Boot 4, the BOM handles even that. All core annotations (@Test, @BeforeEach, @ParameterizedTest, etc.) and assertion methods are identical in JUnit 6. Only custom engine implementations and internal-API-dependent extensions may need minor updates.

Q2: Will JUnit 4 tests still run after upgrading to JUnit 6?

Yes, if you include the junit-vintage-engine dependency β€” but treat it as temporary. As of JUnit 6 and Spring Framework 7, the Vintage engine is deprecated and logs an INFO-level discovery issue whenever it finds JUnit 4 tests. Spring’s own JUnit 4 support (e.g. SpringRunner) is deprecated too. Use Vintage only to keep the build green while you migrate JUnit 4 tests to JUnit Jupiter.

Q3: Does Spring Boot support JUnit 6?

Yes. Spring Boot 4.0 (released 20 November 2025) builds on Spring Framework 7 and ships JUnit 6 as its managed testing baseline β€” you do not set the version yourself. Spring Boot 3.x ships JUnit 5; if you want JUnit 6 there, override the version explicitly in your pom.xml or Gradle file. See the compatibility matrix above and our Spring Boot 3 to 4 Migration Guide for the full upgrade context.

Q4: Are the import statements the same in JUnit 5 and JUnit 6?

Yes. All annotations and assertion classes are still in the org.junit.jupiter.api package. Your import statements do not change when upgrading from JUnit 5 to JUnit 6.

Q5: Is JUnit 6 faster than JUnit 5?

For typical suites the difference is negligible; JUnit 6 reduces engine and extension overhead, but the practical gains only show in very large projects. Parallel execution matters far more, and on Spring Boot 4, Spring Framework 7’s test-context pausing adds a real speedup for big integration suites. See the Performance: JUnit 6 vs JUnit 5 section above for configuration details.

See Also

Conclusion

JUnit 6 is a confident upgrade from JUnit 5. The API is familiar, the migration is low risk, and the improvements β€” cleaner extension model, better records support, refined lifecycle semantics, and JSpecify null safety β€” make your tests cleaner and more maintainable over time. With Spring Boot 4 making JUnit 6 the default, the most common path is no longer β€œshould I migrate?” but β€œit arrived with my framework upgrade” β€” and for most projects, it just works.

Next: JUnit 6 Architecture Deep Dive β€” understand how the Platform, Jupiter, and Vintage engines work together under the hood.

Leave a Reply

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