JUnit 5 Advanced Features

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.

Parameterized tests

The same test logic with different inputs:

@ParameterizedTest
@ValueSource(strings = {"alice", "bob", "carol"})
void shouldAcceptValidUsername(String username) {
    assertTrue(validator.isValid(username));
}

Multiple sources:

AnnotationUse
@ValueSourceSingle primitive or String value
@CsvSourceMultiple values per test, inline
@CsvFileSourceCSV file as source
@MethodSourceMethod-provided arguments
@EnumSourceEnum values
@ArgumentsSourceCustom 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 test classes

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

Dynamic tests

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

Test lifecycle

The standard hooks:

@TestInstance(Lifecycle.PER_CLASS) reuses the same test instance across all methods, allowing non-static @BeforeAll. Useful for expensive setup.

Assertions

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

Extensions

JUnit 5's @ExtendWith mechanism replaces JUnit 4 runners and rules. Extensions hook into the test lifecycle to provide framework integration.

Common extensions

Custom extensions

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

Display names

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

Tagging

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

Conditional execution

Skip tests based on conditions:

@Test
@EnabledOnOs(OS.LINUX)
void linuxOnlyTest() { /* ... */ }

@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void ciOnlyTest() { /* ... */ }

@Test
@DisabledIf("isLegacyMode")
void modernOnlyTest() { /* ... */ }

Common failure patterns

Further Reading