JUnit 5: Parameterized Tests, Dynamic Tests & Lifecycle

JUnit 5 (JUnit Jupiter) is the de-facto standard test framework for Java. It introduces parameterized tests for running the same logic with multiple inputs, dynamic tests generated at runtime, a richer assertion API, and a clean lifecycle model with @BeforeEach, @BeforeAll, @Nested, and @ExtendWith.

Share

Contents

Dependency

Spring Boot projects include JUnit 5 via spring-boot-starter-test. For standalone projects add the JUnit BOM explicitly.

XML
<!-- Spring Boot — already includes JUnit 5 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

<!-- Standalone Maven project -->
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.11.0</version>
  <scope>test</scope>
</dependency>
JUnit 5 is split into three modules: junit-jupiter-api (write tests), junit-jupiter-engine (run tests), and junit-jupiter-params (parameterized tests). The junit-jupiter aggregate pulls all three in one declaration.

Test Lifecycle

JUnit 5 creates a new test instance per method by default (PER_METHOD). Use @TestInstance(Lifecycle.PER_CLASS) to share state across methods and allow @BeforeAll/@AfterAll on non-static methods.

Java
import org.junit.jupiter.api.*;

class OrderServiceTest {

    private OrderService service;

    @BeforeAll
    static void initAll() {
        System.out.println("Once before all tests in this class");
    }

    @BeforeEach
    void init() {
        service = new OrderService();  // fresh instance per test
    }

    @Test
    void createsOrder() {
        Order order = service.create("item-1", 3);
        Assertions.assertNotNull(order.id());
    }

    @Test
    @DisplayName("Rejects zero quantity")
    void rejectsZeroQty() {
        Assertions.assertThrows(IllegalArgumentException.class,
            () -> service.create("item-1", 0));
    }

    @AfterEach
    void tearDown() {
        service = null;
    }

    @AfterAll
    static void cleanAll() {
        System.out.println("Once after all tests");
    }
}

Key lifecycle annotations:

AnnotationRunsMethod must be
@BeforeAllOnce before any test in the classstatic (or PER_CLASS)
@BeforeEachBefore every test methodinstance
@AfterEachAfter every test methodinstance
@AfterAllOnce after all tests in the classstatic (or PER_CLASS)
@DisabledSkips the test (with optional reason)any
@TimeoutFails if test exceeds durationany

Assertions

JUnit 5's Assertions class provides assertAll for grouped checks, assertThrows for exception testing, and assertTimeout for performance assertions: all with optional failure messages.

Java
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

class AssertionExamplesTest {

    @Test
    void groupedAssertions() {
        var user = new User("Alice", "alice@example.com", 30);

        // All assertions are evaluated even if one fails
        Assertions.assertAll("user",
            () -> Assertions.assertEquals("Alice", user.name()),
            () -> Assertions.assertEquals("alice@example.com", user.email()),
            () -> Assertions.assertTrue(user.age() >= 18, "Must be adult")
        );
    }

    @Test
    void exceptionAssertion() {
        var service = new PaymentService();

        // Verify the right exception type is thrown
        IllegalArgumentException ex = Assertions.assertThrows(
            IllegalArgumentException.class,
            () -> service.charge(-10)
        );
        Assertions.assertTrue(ex.getMessage().contains("negative"));
    }

    @Test
    void timeoutAssertion() {
        // Fails if the lambda takes more than 500 ms
        Assertions.assertTimeout(java.time.Duration.ofMillis(500), () -> {
            Thread.sleep(100);
        });
    }

    @Test
    void nullAndInstanceChecks() {
        Object obj = "hello";
        Assertions.assertNotNull(obj);
        Assertions.assertInstanceOf(String.class, obj);
        Assertions.assertNull(null);
    }
}

Parameterized Tests

Annotate a test method with @ParameterizedTest and choose a source annotation. JUnit runs the method once per set of arguments, reporting each invocation separately in the test report.

Java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import org.junit.jupiter.api.Assertions;

class ParameterizedExamplesTest {

    // Single value source — strings
    @ParameterizedTest
    @ValueSource(strings = { "racecar", "level", "deified" })
    void isPalindrome(String word) {
        Assertions.assertTrue(StringUtils.isPalindrome(word));
    }

    // Multiple columns — CSV inline
    @ParameterizedTest
    @CsvSource({
        "Alice,  25, true",
        "Bob,    17, false",
        "Charlie, 0, false"
    })
    void isEligible(String name, int age, boolean expected) {
        Assertions.assertEquals(expected, EligibilityChecker.check(age));
    }

    // From a static factory method
    @ParameterizedTest
    @MethodSource("orderProvider")
    void processesOrder(String product, int qty, double expectedTotal) {
        double total = new PricingService().calculate(product, qty);
        Assertions.assertEquals(expectedTotal, total, 0.001);
    }

    static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> orderProvider() {
        return java.util.stream.Stream.of(
            Arguments.of("Widget", 1,  9.99),
            Arguments.of("Widget", 5, 49.95),
            Arguments.of("Gadget", 2, 29.98)
        );
    }

    // Enum source
    @ParameterizedTest
    @EnumSource(value = OrderStatus.class, names = { "PENDING", "PROCESSING" })
    void isActiveStatus(OrderStatus status) {
        Assertions.assertTrue(status.isActive());
    }

    // Load from CSV file on the classpath
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data/prices.csv", numLinesToSkip = 1)
    void pricesFromFile(String sku, double price) {
        Assertions.assertTrue(price > 0);
    }
}

Dynamic Tests (@TestFactory)

@TestFactory generates test cases at runtime by returning a Stream, Collection, or Iterable of DynamicTest objects. Use it when the number of tests or their names can't be determined at compile time.

Java
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import org.junit.jupiter.api.Assertions;

class DynamicTestExamplesTest {

    @TestFactory
    Stream<DynamicTest> roundTripTests() {
        // Generate a test for each supported currency
        return Stream.of("USD", "EUR", "GBP", "JPY")
            .map(currency -> dynamicTest(
                "Round-trip for " + currency,
                () -> {
                    String encoded = CurrencyEncoder.encode(100, currency);
                    Assertions.assertEquals(currency, CurrencyEncoder.decode(encoded).currency());
                }
            ));
    }

    @TestFactory
    Stream<DynamicTest> fromDatabase() {
        // Tests driven by a list from a data source
        return TestDataRepository.loadCases().stream()
            .map(tc -> dynamicTest(tc.name(), () -> {
                var result = new Calculator().evaluate(tc.expression());
                Assertions.assertEquals(tc.expected(), result);
            }));
    }
}

@Nested: Grouping Related Tests

@Nested inner classes let you organise tests into groups with their own @BeforeEach/@AfterEach setup. The outer class's @BeforeEach still runs first, giving a layered setup hierarchy.

Java
import org.junit.jupiter.api.*;

@DisplayName("ShoppingCart")
class ShoppingCartTest {

    ShoppingCart cart;

    @BeforeEach
    void init() { cart = new ShoppingCart(); }

    @Nested
    @DisplayName("when empty")
    class WhenEmpty {

        @Test void hasZeroItems()  { Assertions.assertEquals(0, cart.size()); }
        @Test void totalIsZero()   { Assertions.assertEquals(0.0, cart.total()); }
        @Test void isEmptyReturnsTrue() { Assertions.assertTrue(cart.isEmpty()); }
    }

    @Nested
    @DisplayName("after adding one item")
    class AfterAddingItem {

        @BeforeEach
        void addItem() { cart.add(new Item("Widget", 9.99)); }

        @Test void hasOneItem()    { Assertions.assertEquals(1, cart.size()); }
        @Test void totalIsPrice()  { Assertions.assertEquals(9.99, cart.total()); }
        @Test void isNotEmpty()    { Assertions.assertFalse(cart.isEmpty()); }

        @Nested
        @DisplayName("after removing the item")
        class AfterRemoving {
            @BeforeEach void remove() { cart.remove("Widget"); }
            @Test void isEmpty() { Assertions.assertTrue(cart.isEmpty()); }
        }
    }
}

Extensions & @ExtendWith

Extensions replace JUnit 4's @RunWith. Compose multiple extensions via repeated @ExtendWith. Commonly used extensions: MockitoExtension, SpringExtension, WireMockExtension.

Java
import org.junit.jupiter.api.extension.*;

// Custom extension that injects a temp directory
public class TempDirExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext ctx) throws Exception {
        // Inject via store
        java.nio.file.Path tmp = java.nio.file.Files.createTempDirectory("junit5-");
        ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("tmpDir", tmp);
    }

    @Override
    public void afterEach(ExtensionContext ctx) throws Exception {
        java.nio.file.Path tmp = ctx.getStore(ExtensionContext.Namespace.GLOBAL)
                                    .get("tmpDir", java.nio.file.Path.class);
        deleteRecursively(tmp);
    }

    private void deleteRecursively(java.nio.file.Path dir) throws Exception {
        java.nio.file.Files.walk(dir)
            .sorted(java.util.Comparator.reverseOrder())
            .forEach(p -> p.toFile().delete());
    }
}
Java
// Using multiple extensions together
@ExtendWith({ MockitoExtension.class, TempDirExtension.class })
class FileProcessorTest {

    @Mock OrderRepository repo;         // injected by MockitoExtension

    @TempDir java.nio.file.Path tmpDir; // JUnit 5 built-in @TempDir

    @Test
    void processesFile() {
        // tmpDir is a fresh empty directory for this test
        Assertions.assertTrue(tmpDir.toFile().exists());
    }
}
JUnit 5 ships a built-in @TempDir annotation that injects a temporary directory automatically: no custom extension needed for that specific case. Use @ExtendWith for cross-cutting concerns like mock injection, timing, or test data setup.