JUnit 5 (Jupiter) replaced JUnit 4 in the late 2010s. Beyond the basic test-method-with-@Test-annotation pattern, it added features that change how more complex tests are written: parameterized tests, nested classes, dynamic tests, and a richer extension model. This page covers the features that pay off in practice.
The same test logic with different inputs:
@ParameterizedTest
@ValueSource(strings = {"alice", "bob", "carol"})
void shouldAcceptValidUsername(String username) {
assertTrue(validator.isValid(username));
}
Multiple sources:
| Annotation | Use |
|---|---|
@ValueSource | Single primitive or String value |
@CsvSource | Multiple values per test, inline |
@CsvFileSource | CSV file as source |
@MethodSource | Method-provided arguments |
@EnumSource | Enum values |
@ArgumentsSource | Custom argument provider |
@ParameterizedTest
@CsvSource({
"alice@example.com, true",
"invalid, false",
"', false"
})
void shouldValidateEmails(String input, boolean expected) {
assertEquals(expected, EmailValidator.isValid(input));
}
Reduces test duplication and makes coverage explicit.
@Nested for hierarchical organization:
class OrderTest {
@Nested
class WhenOrderIsPending {
@Test
void canBeCancelled() { /* ... */ }
@Test
void cannotBeShipped() { /* ... */ }
}
@Nested
class WhenOrderIsConfirmed {
@Test
void cannotBeCancelled() { /* ... */ }
@Test
void canBeShipped() { /* ... */ }
}
}
Useful for representing state-dependent behavior. Each @Nested class can have its own setup; tests within share that setup.
Tests generated at runtime:
@TestFactory
Stream<DynamicTest> shouldHandleAllSupportedFormats() {
return Stream.of("json", "xml", "yaml")
.map(format -> DynamicTest.dynamicTest(
"should parse " + format,
() -> assertNotNull(parser.parse(format, sampleInput))));
}
Useful when test cases depend on runtime data (file system contents, database state, configuration).
The standard hooks:
@BeforeAll: once before all tests in the class (must be static unless @TestInstance(Lifecycle.PER_CLASS))@BeforeEach: before each test method@AfterEach: after each test method@AfterAll: once after all tests in the class@TestInstance(Lifecycle.PER_CLASS) reuses the same test instance across all methods, allowing non-static @BeforeAll. Useful for expensive setup.
JUnit 5 assertions:
assertEquals(expected, actual);
assertTrue(condition);
assertNotNull(obj);
assertThrows(IllegalArgumentException.class, () -> service.process(invalid));
assertTimeout(Duration.ofSeconds(1), () -> longRunning());
// Multiple assertions; runs all even if some fail
assertAll(
() -> assertEquals("abc", result.id()),
() -> assertTrue(result.amount() > 0),
() -> assertEquals(OrderStatus.PENDING, result.status())
);
For more readable assertions, AssertJ's fluent API is widely preferred:
assertThat(result)
.isNotNull()
.extracting(Order::id, Order::amount, Order::status)
.contains("abc", 99.0, OrderStatus.PENDING);
JUnit 5's @ExtendWith mechanism replaces JUnit 4 runners and rules. Extensions hook into the test lifecycle to provide framework integration.
@ExtendWith(MockitoExtension.class) — Mockito support@ExtendWith(SpringExtension.class) — Spring tests (often via @SpringBootTest)@ExtendWith(MyExtension.class) — custom extensionsFor project-specific test infrastructure:
public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
@Override
public void beforeTestExecution(ExtensionContext context) {
context.getStore(NAMESPACE).put("startTime", System.nanoTime());
}
@Override
public void afterTestExecution(ExtensionContext context) {
long duration = System.nanoTime() - (long) context.getStore(NAMESPACE).get("startTime");
System.out.println(context.getDisplayName() + " took " + duration + "ns");
}
}
Extensions earn their place when test infrastructure is shared across many tests.
@Test
@DisplayName("should reject invalid email formats")
void shouldRejectInvalid() { /* ... */ }
The display name appears in test reports. For tests with names that read as sentences, this is helpful.
For parameterized tests, the parameter values are in the display name automatically.
@Test
@Tag("slow")
void integrationTest() { /* ... */ }
Useful with build tools to run subsets:
mvn test -Dgroups=fast
mvn test -Dgroups=integration
Maintains the unit/integration distinction without separate source directories.
Skip tests based on conditions:
@Test
@EnabledOnOs(OS.LINUX)
void linuxOnlyTest() { /* ... */ }
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void ciOnlyTest() { /* ... */ }
@Test
@DisabledIf("isLegacyMode")
void modernOnlyTest() { /* ... */ }
@BeforeEach should reset; otherwise tests interfere.assertTimeout or proper synchronization.@Disabled and forgetting them. Run periodically; remove or fix.