How do I write dynamic tests with @TestFactory?

@TestFactory is a JUnit Jupiter feature that lets you generate tests at runtime rather than declaring them statically with @Test. This is useful when the number or nature of tests depends on data that’s only known at execution time.

Key Rules

  • A @TestFactory method must return one of:
    • DynamicNode (or a subtype like DynamicTest / DynamicContainer)
    • Stream, Collection, Iterable, Iterator, or an array of DynamicNode
  • It must not be private or static.
  • Each generated DynamicTest consists of a display name and an Executable (lambda with the assertion logic).

1. Basic Example — Collection of Tests

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

import java.util.List;

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

class CalculatorDynamicTests {

    @TestFactory
    List<DynamicTest> additionTests() {
        return List.of(
            dynamicTest("1 + 1 = 2", () -> assertEquals(2, 1 + 1)),
            dynamicTest("2 + 3 = 5", () -> assertEquals(5, 2 + 3)),
            dynamicTest("10 + 5 = 15", () -> assertEquals(15, 10 + 5))
        );
    }
}

2. Generating Tests from a Stream

Great for data-driven scenarios:

import java.util.stream.Stream;

@TestFactory
Stream<DynamicTest> squareTests() {
    return Stream.of(1, 2, 3, 4, 5)
        .map(n -> dynamicTest(
            "square of " + n + " is " + (n * n),
            () -> assertEquals(n * n, n * n)
        ));
}

3. Using an Input Generator, Display-Name Generator, and Test Executor

The DynamicTest.stream(...) helper simplifies iterator-based generation:

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

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

import static org.junit.jupiter.api.Assertions.assertTrue;

class PalindromeDynamicTests {

    @TestFactory
    Stream<DynamicTest> palindromeTests() {
        Iterator<String> inputs = List.of("racecar", "level", "madam").iterator();

        return DynamicTest.stream(
            inputs,
            input -> "isPalindrome('" + input + "')",
            input -> assertTrue(isPalindrome(input))
        );
    }

    private boolean isPalindrome(String s) {
        return new StringBuilder(s).reverse().toString().equals(s);
    }
}

4. Grouping Tests with DynamicContainer

You can nest dynamic tests into containers to build a hierarchy:

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

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

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

class MathDynamicTests {

    @TestFactory
    Stream<DynamicNode> mathOperations() {
        return Stream.of(
            dynamicContainer("Addition", List.of(
                dynamicTest("1+1", () -> assertEquals(2, 1 + 1)),
                dynamicTest("2+2", () -> assertEquals(4, 2 + 2))
            )),
            dynamicContainer("Multiplication", List.of(
                dynamicTest("2*3", () -> assertEquals(6, 2 * 3)),
                dynamicTest("4*5", () -> assertEquals(20, 4 * 5))
            ))
        );
    }
}

5. Reading Test Data from a File

Perfect for parameterized-style tests where inputs live outside code:

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

@TestFactory
Stream<DynamicTest> testsFromFile() throws Exception {
    return Files.lines(Path.of("src/test/resources/cases.csv"))
        .map(line -> line.split(","))
        .map(parts -> dynamicTest(
            "Case: " + parts[0],
            () -> assertEquals(Integer.parseInt(parts[2]),
                               Integer.parseInt(parts[0]) + Integer.parseInt(parts[1]))
        ));
}

When to Use @TestFactory vs @ParameterizedTest

Use @ParameterizedTest Use @TestFactory
Fixed set of arguments, single test body Fully dynamic generation (count, names, logic can all vary)
Simple data variations Hierarchical / conditional / streamed test generation
Compile-time known inputs Runtime-computed inputs

Important Lifecycle Note

⚠️ Standard lifecycle callbacks like @BeforeEach and @AfterEach do not run around each generated dynamic test — only around the factory method itself. If you need per-test setup, do it inside each Executable.