JUnit 5 is not one library but three. The JUnit Platform discovers and launches tests and reports results to build tools and IDEs. JUnit Jupiter is the programming model you write tests against, with @Test, @BeforeEach, parameterized tests and an extension API. JUnit Vintage is an engine that runs old JUnit 3 and 4 tests on the same platform. Understanding that split explains most of the behaviour that surprises people: why tests are silently not found, why a JUnit 4 rule does nothing, and how Spring or Mockito plug in.
The Jupiter API has also outlived its version number. JUnit 6.0.0, released on 30 September 2025, unified the version numbers of Platform, Jupiter and Vintage and raised the baseline to Java 17, but kept the Jupiter programming model, so everything below applies to both. The article builds from the architecture to the lifecycle, then through the features you use daily, and ends with an extension you can copy, parallel execution and a migration plan.
Architecture: launcher, engines and discovery
A test run has two phases. In discovery the launcher receives a request, such as all classes on the test classpath, filtered by tag, and asks every engine on the classpath to build a tree of test descriptors. In execution it walks that tree and each engine runs its own tests, sending started, skipped and finished events to listeners that produce reports.
Engines are found through Java's service loader, which has a practical consequence: if no engine is on the test runtime classpath, the build reports zero tests and succeeds. The usual cause is depending on junit-jupiter-api alone, which contains the annotations but not the engine. Depend on the junit-jupiter aggregate, import the BOM so all modules share a version, and make the build fail when no tests ran.
// build.gradle.kts
dependencies {
testImplementation(platform("org.junit:junit-bom:<version>")) // one version for all modules
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine") // only while JUnit 4 tests remain
}
tasks.test {
useJUnitPlatform { excludeTags("slow") }
}
The lifecycle: one instance per test method
By default Jupiter creates a new instance of the test class for every test method. The sequence for each method is: construct the class, run @BeforeEach methods, run the test, run @AfterEach methods. @BeforeAll and @AfterAll run once per class and must therefore be static, because no instance exists yet.
Per-method instances are the main defence against order-dependent tests: an instance field set by one test cannot leak into the next. Static fields, singletons and shared databases can still leak, which is where most flaky suites come from. You can switch a class to @TestInstance(Lifecycle.PER_CLASS), which makes @BeforeAll non-static and lets tests share an expensive fixture, but you then own resetting it. Do this only when setup is genuinely costly, such as starting a server, and reset mutable state in @BeforeEach.
Test methods run in a deterministic but intentionally non-obvious order. Never rely on it. If a scenario really is a sequence, make it one test, or use @TestMethodOrder(MethodOrderer.OrderAnnotation.class) with @Order and accept that the tests are no longer independent.
Writing tests: assertions, parameterized and nested tests
The worked example tests an invoice. It shows the features you use most.
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@DisplayName("Invoice totals")
class InvoiceTest {
private Invoice invoice;
@BeforeEach
void newInvoice() {
invoice = new Invoice("EUR"); // fresh fixture for every test method
}
@Test
void emptyInvoiceTotalsZero() {
assertEquals(new BigDecimal("0.00"), invoice.total());
}
@Test
void rejectsNegativeQuantity() {
var ex = assertThrows(IllegalArgumentException.class,
() -> invoice.add("SKU-1", -1, new BigDecimal("9.99")));
assertTrue(ex.getMessage().contains("quantity"));
}
@ParameterizedTest(name = "{0} x {1} at {2} = {3}")
@CsvSource(textBlock = """
1, 10.00, 0.00, 10.00
3, 9.99, 0.10, 26.97
2, 5.00, 1.00, 0.00
""")
void appliesDiscount(int qty, BigDecimal unit, BigDecimal discount, BigDecimal expected) {
invoice.add("SKU-1", qty, unit);
invoice.applyDiscount(discount);
assertEquals(expected, invoice.total());
}
@Nested
class WhenFinalised {
@BeforeEach
void finalise() { invoice.add("SKU-1", 1, BigDecimal.TEN); invoice.finalise(); }
@Test
void cannotBeEdited() {
assertAll(
() -> assertThrows(IllegalStateException.class, () -> invoice.add("SKU-2", 1, BigDecimal.ONE)),
() -> assertEquals(new BigDecimal("10.00"), invoice.total()));
}
}
}assertThrowsreturns the exception so you can assert on it, replacing JUnit 4'sexpectedattribute, which passed if any line threw.assertAllruns every assertion and reports all failures together, instead of stopping at the first.@ParameterizedTestruns one method per row.@CsvSourceconverts strings to the parameter types, includingBigDecimal. Other sources include@ValueSource,@EnumSource,@MethodSourcereturning a stream ofArguments, and@NullAndEmptySource. Thenameattribute makes each row readable in reports.@Nestedinner classes group tests by state. The outer@BeforeEachruns before the inner one, so the nested class reads as a given-when-then story.- Assumptions such as
assumeTrue(isLinux())abort a test as skipped rather than failed, which is right for environment-dependent tests. @Taglabels tests, for example as slow or integration, and build tools filter on tags. Combine it with@Testinto your own meta-annotation, because Jupiter annotations compose; the Java annotations article explains meta-annotations and retention.
Two built-ins save boilerplate: @TempDir Path dir gives each test a fresh directory that is deleted afterwards, and @Timeout(5) fails a test that runs longer than five seconds. For fluent assertions many teams add AssertJ on top; Jupiter's own assertions are deliberately minimal.
When the cases are only known at run time, such as one test per file in a fixtures directory, use a @TestFactory method that returns a stream of DynamicTest objects, each built with DynamicTest.dynamicTest(name, executable). Dynamic tests appear individually in reports, but they do not get per-test @BeforeEach or @AfterEach calls: the lifecycle methods run once around the whole factory. Put per-case setup inside the executable, or prefer a parameterized test with @MethodSource when the full lifecycle matters.
The extension model
JUnit 4 had runners and rules, and a class could have only one runner. Jupiter replaces both with extensions: small objects that implement callback interfaces and are registered with @ExtendWith, with a static @RegisterExtension field when you need to configure the instance, or globally through the service loader. Many extensions can apply to one test, which is how Spring's SpringExtension and Mockito's MockitoExtension coexist.
The main extension points are lifecycle callbacks (BeforeAllCallback, BeforeEachCallback, AfterEachCallback and their siblings), ParameterResolver for injecting test method and constructor parameters, ExecutionCondition for deciding whether to run a test, TestExecutionExceptionHandler and TestWatcher for reacting to results. Extensions keep state in the ExtensionContext.Store, scoped to a namespace and to the current test or class, rather than in fields, because the same extension instance may serve many tests, possibly in parallel.
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.extension.*;
/** Injects a fixed Clock and records per-test timing in the extension store. */
public class FixedClockExtension implements ParameterResolver, BeforeEachCallback, AfterEachCallback {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(FixedClockExtension.class);
@Override
public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
return pc.getParameter().getType() == Clock.class;
}
@Override
public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
return Clock.fixed(Instant.parse("2026-10-01T09:00:00Z"), ZoneOffset.UTC);
}
@Override
public void beforeEach(ExtensionContext ec) {
ec.getStore(NS).put("start", System.nanoTime());
}
@Override
public void afterEach(ExtensionContext ec) {
long start = ec.getStore(NS).remove("start", long.class);
ec.publishReportEntry("elapsedMicros", String.valueOf((System.nanoTime() - start) / 1_000));
}
}
// Usage: parameters are resolved per method, no static state needed.
@ExtendWith(FixedClockExtension.class)
class DueDateTest {
@Test
void dueInThirtyDays(Clock clock) {
assertEquals(LocalDate.of(2026, 10, 31), DueDates.from(clock).plusDays(30));
}
}The extension injects a fixed Clock, which makes date logic deterministic, and publishes per-test timing as a report entry. Discovery of the parameter type works through reflection on the method signature; the reflection article covers what that costs and why it is fine at test scale.
Parallel execution
Jupiter runs tests sequentially unless you enable parallelism, which is done in junit-platform.properties on the test classpath. The safest starting point is to run classes concurrently while keeping methods within a class on one thread:
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
# methods inside one class run on the same thread ...
junit.jupiter.execution.parallel.mode.default = same_thread
# ... but different classes run concurrently
junit.jupiter.execution.parallel.mode.classes.default = concurrentTests that touch shared resources then need coordination. @ResourceLock("customer-db") makes tests that name the same resource run one at a time, with a READ mode for tests that only read. @Isolated runs a class with nothing else in parallel, and @Execution(ExecutionMode.SAME_THREAD) opts a class out. Anything that mutates system properties, environment-dependent singletons or static caches needs a lock. The pool is a fork-join pool sized from the number of processors by default, so tests that block on I/O or sleep reduce throughput; the ForkJoinPool article explains why blocking inside it is costly.
A worked estimate: a suite with 400 classes taking 12 minutes on one thread, of which 70 classes share a database, runs in roughly 3 to 4 minutes on 8 cores with class-level concurrency and a resource lock on the database classes. The locked classes now form a serial tail, so the next speed-up comes from giving them separate schemas, not more cores.
Migrating from JUnit 4
- Add the Jupiter and Vintage engines side by side. Existing tests keep running through Vintage while new tests use Jupiter; both appear in one report.
- Convert mechanically:
org.junit.Testtoorg.junit.jupiter.api.Test,@Beforeto@BeforeEach,@BeforeClassto@BeforeAll,@Ignoreto@Disabled, and@Categoryto@Tag. Assertion argument order changes: the failure message moves from first to last. - Replace rules with extensions or built-ins:
TemporaryFolderbecomes@TempDir,ExpectedExceptionbecomesassertThrows, and custom rules become callback extensions. - Replace runners:
@RunWith(Parameterized.class)becomes@ParameterizedTest, and framework runners become their extensions, such as@ExtendWith(MockitoExtension.class). - Remove Vintage once no JUnit 4 imports remain, and add an architecture check so none come back.
What JUnit 6 changed
If you are starting today you will likely use the 6.x line. It requires Java 17 or later, deprecates the JRE enum constants for Java 8 to 16 used by @EnabledOnJre and related conditions, makes the order of @Nested classes deterministic, switches CSV parsing for @CsvSource and @CsvFileSource to the FastCSV library, adds JSpecify nullness annotations and a cancellation API with fail-fast support in the console launcher, and supports Kotlin suspending test functions. Test code written against the Jupiter API generally compiles unchanged; check the release notes for removed deprecated APIs, and re-run CSV-driven tests, because edge cases in quoting may now parse differently.
Failure modes
- Zero tests found, build green. Missing engine, a JUnit 4
@Testimport with no Vintage engine, or a test task not configured for the platform. Fail the build when the test count is zero. - Flaky only in CI. Shared static state or order dependence exposed by parallelism or a different method order. Find it by running the suspect class alone and repeatedly with
@RepeatedTest. - Timeouts that break transactions.
assertTimeoutPreemptivelyruns the code in a different thread, so thread-bound state such as a Spring transaction or aThreadLocalis not visible. PreferassertTimeoutor@Timeoutwhen the code depends on the calling thread. - Async code asserted too early. A test that starts a CompletableFuture and asserts immediately passes or fails by luck. Join the future with a timeout, or inject an executor that runs tasks synchronously.
- Slow suites from per-test setup. Starting a container or application context for every method. Share it per class with care, or per run through an extension, and reset state between tests.
What to do next
- Import the JUnit BOM, depend on
junit-jupiterplus the launcher, and fail builds that execute zero tests. - Convert one JUnit 4 module with Vintage alongside, then remove Vintage when no JUnit 4 imports remain.
- Replace hand-rolled loops over test cases with
@ParameterizedTestand readablenamepatterns. - Move shared test setup, such as clocks, containers or credentials, into an extension that stores state in the ExtensionContext store.
- Enable class-level parallel execution, add
@ResourceLockto tests that share state, and measure the new suite time. - Tag slow and integration tests and run them in a separate CI stage.