Skip to content

@TestFactory: Dynamic Tests in JUnit 5

A Real World Problem: The Daily Settlement File

Imagine you work for a payment gateway company. Every night the bank sends you a file called settlement.csv containing hundreds of transaction rows. Your job is to write automated tests that validate each row.

Now consider two very different situations:

Situation A: The file schema is fixed. You always have three columns: transactionId, amount, currency. Every row needs the same validation: amount must be a valid integer and must be greater than zero. In this case parameterized tests work beautifully. You write one test method and it runs once per row with the same assertions applied uniformly.

Situation B: The file schema can change. Some days there are three columns, some days four. The column order may shift. Worse, different rows need different validation rules. A row with currency INR might require two checks (amount is integer AND amount is greater than zero), while a row with currency USD only needs one check (amount is integer). One test method with the same assertion block cannot handle this because every row of the parameterized test runs the exact same set of assertions.

This second situation is precisely where @TestFactory and dynamic tests shine. A dynamic test is created fresh for each row, so each one can carry a completely different set of assertions.


What Is @TestFactory?

@TestFactory is an annotation in JUnit 5 that marks a method as a factory for tests, not as a test itself. Where @Test marks a static test (one whose inputs and assertions are fixed at compile time), @TestFactory marks a method whose job is to generate test cases dynamically at runtime.

Key distinction:

Feature@Test@TestFactory
Test known atCompile timeRuntime
ExecutableMethod referenceLambda expression
@BeforeEach / @AfterEach supportYesNo (only @BeforeAll / @AfterAll)
Use caseStable, known inputsDynamic, varying inputs

The lack of @BeforeEach and @AfterEach support inside dynamic tests is intentional. Because the framework does not know at compile time what tests will be created, it cannot wire lifecycle callbacks to them. @BeforeAll and @AfterAll still run at the factory method level.


The Architecture: How JUnit 5 Executes Tests

To understand dynamic tests deeply you need to recall the two phases of JUnit 5 test execution:

  1. Discovery phase — the engine scans the codebase and produces TestDescriptor objects. Each descriptor carries two critical things: a display name and an executable.
  2. Execution phase — the platform takes each TestDescriptor and runs its executable.

For a regular @Test method the display name is the method name and the executable is a lambda that uses reflection to invoke the method. For a @TestFactory method the executable is the lambda expression you provide directly. From the Jupiter engine's perspective both are treated the same way: give me a display name and give me something executable.


The Dynamic Test Hierarchy: Composite Pattern

JUnit 5 models dynamic tests using the Composite design pattern with three classes:

DynamicNode  (abstract parent)
├── DynamicTest       (leaf node — an actual test case with a lambda)
└── DynamicContainer  (non-leaf node — a logical grouping, like a folder)
    ├── DynamicTest
    ├── DynamicTest
    └── DynamicContainer
        └── DynamicTest

Think of DynamicContainer as a folder and DynamicTest as a file. A folder can contain files and more folders. A DynamicTest is what actually executes — it wraps a display name and a lambda. A DynamicContainer is only a grouping mechanism; it does not execute anything itself.


Writing a Single Dynamic Test

java
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class MathDynamicTest {

    /**
     * A @TestFactory method returns a single DynamicTest (or DynamicNode).
     * The factory method itself is NOT the test — it generates the test.
     */
    @TestFactory
    DynamicTest singleAdditionTest() {
        return dynamicTest(
            "addition of 4 and 3 should be 7",          // display name
            () -> assertEquals(7, 4 + 3)                 // executable (lambda)
        );
    }
}

When JUnit runs this, it invokes singleAdditionTest(), receives one DynamicTest, and executes its lambda. The output shows one test case named "addition of 4 and 3 should be 7".


Writing Multiple Dynamic Tests

When you need more than one dynamic test, your factory method must return a stream, collection, array, or iterator of DynamicTest objects.

Using Stream (most common)

java
import java.util.stream.Stream;

@TestFactory
Stream<DynamicTest> positiveNumberTests() {
    return Stream.of(1, 2, 3, -5)
        .map(n -> dynamicTest(
            "Number " + n + " should be positive",
            () -> assertTrue(n > 0)   // will fail for -5
        ));
}

Four dynamic tests are generated, one per input. Each has its own display name and its own lambda.

Using Collection

java
import java.util.ArrayList;
import java.util.List;

@TestFactory
List<DynamicTest> positiveNumberTestsAsCollection() {
    List<DynamicTest> tests = new ArrayList<>();
    for (int n : new int[]{1, 2, 3, -5}) {
        tests.add(dynamicTest(
            "Number " + n + " positivity check",
            () -> assertTrue(n > 0)
        ));
    }
    return tests;
}

Use whichever return type matches your data source. If your data is already in a List, return a collection. If you are processing a stream (e.g. from a file reader or DB result set), return a Stream<DynamicTest>.

Accepted return types:

  • Stream<DynamicTest> or Stream<DynamicNode>
  • Collection<DynamicTest>
  • DynamicTest[]
  • Iterator<DynamicTest>

Real World Example: The Settlement CSV with Varying Validation

This example directly models the payment gateway scenario described in the opening.

java
import org.junit.jupiter.api.DynamicContainer;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class SettlementFileTest {

    /**
     * Simulated CSV data: { transactionId, amount, currency }
     * In a real application this would be read from a file or database.
     */
    private static final List<String[]> SETTLEMENT_ROWS = List.of(
        new String[]{"TXN001", "500", "INR"},
        new String[]{"TXN002", "ABC", "USD"}   // 'ABC' is not a valid integer — test should fail
    );

    /**
     * For INR rows we create TWO dynamic tests:
     *   1. Amount is a valid integer.
     *   2. Amount is greater than zero.
     *
     * For all other currencies we create ONE dynamic test:
     *   1. Amount is a valid integer.
     *
     * This per-row variation is impossible with @ParameterizedTest alone.
     */
    @TestFactory
    Stream<DynamicContainer> validateSettlementRows() {
        return SETTLEMENT_ROWS.stream()
            .map(row -> {
                String transactionId = row[0];
                String amount        = row[1];
                String currency      = row[2];

                List<DynamicTest> testsForRow = new ArrayList<>();

                // Test 1: applies to every row regardless of currency
                testsForRow.add(dynamicTest(
                    "[" + transactionId + "] Amount is a valid integer",
                    () -> assertTrue(amount.matches("-?\\d+"),
                        "Expected amount to be numeric but got: " + amount)
                ));

                // Test 2: only applicable for INR transactions
                if ("INR".equals(currency)) {
                    testsForRow.add(dynamicTest(
                        "[" + transactionId + "] INR amount is greater than zero",
                        () -> assertTrue(Integer.parseInt(amount) > 0,
                            "INR amount must be positive")
                    ));
                }

                // Group all tests for this row under a named container
                return dynamicContainer("Row: " + transactionId, testsForRow.stream());
            });
    }
}

Output structure:

SettlementFileTest
└── validateSettlementRows()
    ├── Row: TXN001
    │   ├── [TXN001] Amount is a valid integer  ✓
    │   └── [TXN001] INR amount is greater than zero  ✓
    └── Row: TXN002
        └── [TXN002] Amount is a valid integer  ✗  (ABC is not numeric)

Row TXN001 has two tests. Row TXN002 has one test and it fails because "ABC" is not a valid integer. This per-row variation is the defining advantage of @TestFactory.


Nested DynamicContainers

DynamicContainer can hold other DynamicContainer objects, enabling arbitrary depth nesting:

java
@TestFactory
DynamicContainer nestedContainerExample() {
    // Inner container with one dynamic test
    DynamicContainer nestedContainer = dynamicContainer(
        "Nested validations",
        Stream.of(
            dynamicTest("Inner test: 2 + 2 = 4", () -> assertEquals(4, 2 + 2))
        )
    );

    // Outer (parent) container holds both a plain test and the nested container
    DynamicTest simpleTest = dynamicTest(
        "Outer test: 1 + 1 = 2",
        () -> assertEquals(2, 1 + 1)
    );

    return dynamicContainer(
        "Parent container",
        Stream.of(simpleTest, nestedContainer)
    );
}

This is analogous to a directory with both files and subdirectories.


When to Use @TestFactory vs @ParameterizedTest

ScenarioBest tool
Same validation for all rows of a file@ParameterizedTest with @CsvFileSource
Different validations per row@TestFactory
Schema changes between file deliveries@TestFactory
DB rows each requiring unique assertions@TestFactory
Simple repeated invocations@RepeatedTest or @ParameterizedTest

In practice, around 90 to 95 percent of scenarios can be handled with parameterized tests. Dynamic tests are the right tool specifically when the number or nature of assertions must vary per data record.


Lifecycle Behavior Summary

java
class LifecycleDynamicTest {

    @BeforeAll
    static void beforeAll() {
        // Runs once before the @TestFactory method is invoked — supported
        System.out.println("Before all");
    }

    @AfterAll
    static void afterAll() {
        // Runs once after all dynamic tests complete — supported
        System.out.println("After all");
    }

    @BeforeEach
    void beforeEach() {
        // Does NOT run before each individual DynamicTest — not supported
        System.out.println("Before each (ignored for dynamic tests)");
    }

    @TestFactory
    Stream<DynamicTest> dynamicTests() {
        return Stream.of("a", "b", "c")
            .map(s -> dynamicTest("test for " + s, () -> assertNotNull(s)));
    }
}

@BeforeEach and @AfterEach are not invoked around each DynamicTest. If per-test setup/teardown is a hard requirement, reconsider whether a parameterized test is a better fit.


Interview Questions & Pitfalls

Q1. What is the difference between @Test and @TestFactory?

@Test marks a static test known at compile time. Its executable is the method body itself, invoked via reflection. @TestFactory marks a factory method that generates DynamicTest instances at runtime. The test cases it produces are not known until the factory method actually runs.

Q2. Does @BeforeEach run before each dynamic test created by @TestFactory?

No. @BeforeEach and @AfterEach do not run around individual dynamic tests. Only @BeforeAll and @AfterAll are supported at the factory method level. This is a common interview trap and a real pitfall in code reviews.

Q3. Why would you choose @TestFactory over @ParameterizedTest when both can read from a CSV file?

With @ParameterizedTest every invocation runs the same test method with the same assertions — only the inputs differ. With @TestFactory each generated DynamicTest can have a completely different set of assertions. When rows in a file require different validation logic, @TestFactory is the correct choice.

Q4. What return types does a @TestFactory method support?

Stream<DynamicNode>, Stream<DynamicTest>, Stream<DynamicContainer>, Collection<DynamicTest>, DynamicTest[], and Iterator<DynamicTest>. Using DynamicNode as the return type is also valid because both DynamicTest and DynamicContainer extend it.

Q5. What is a DynamicContainer and how does it relate to DynamicTest?

DynamicContainer is a non-leaf grouping node, analogous to a folder. It can contain any number of DynamicTest instances and other DynamicContainer instances. DynamicTest is the leaf node — it is the actual executable test. Both extend DynamicNode, which follows the Composite design pattern.

Q6. Can DynamicContainer be nested inside another DynamicContainer?

Yes, to arbitrary depth. A container can hold both dynamic tests and other containers, creating a tree structure. This mirrors a filesystem where folders contain files and subfolders.

Q7. What is a common production pitfall when using @TestFactory?

Assuming @BeforeEach and @AfterEach will run around each dynamic test. Since they do not, any state that must be initialized before each test must be done inside the lambda itself. Developers accustomed to @Test often miss this and introduce subtle state pollution between dynamic tests.