@TestFactory Dynamic Tests
JUnit 5 introduces @TestFactory as a mechanism for generating tests at runtime. Unlike a standard @Test method, which represents a single test case fixed at compile time, a method annotated with @TestFactory is not itself a test. Instead, it acts as a factory that produces one or more dynamic test nodes when the test suite runs. The JUnit platform then executes each generated node individually and reports results separately.
This distinction matters because it shifts test definition from static source code to runtime logic. When test data originates from external files, database records, remote APIs, or complex business rules, traditional annotations often fall short. @TestFactory fills that gap by allowing tests to be constructed on demand, based on whatever information is available when the test engine executes.
How It Differs from @Test and @ParameterizedTest
To understand where @TestFactory fits, it helps to compare it with the two most common testing annotations in JUnit 5.
@Test: One method maps to one test case. The test structure is fully determined when the code compiles.@ParameterizedTest: One method runs multiple times with different argument sets. The data source is usually declared statically through annotations such as@ValueSource,@CsvSource, or@MethodSource.@TestFactory: One method returns a collection ofDynamicNodeobjects, typicallyDynamicTestorDynamicContainer. The actual tests do not exist until the factory method is invoked at runtime.
Because @TestFactory defers test creation to execution time, it is especially suitable for data-driven testing, irregular test structures, and deeply nested scenarios. Every dynamic test it produces appears as a distinct entry in test reports, so a failure isolates to a specific case rather than failing the entire factory method.
Method Requirements and Return Types
A @TestFactory method must follow several constraints:
- It cannot be declared
privateorstatic. - Its return type must be
Stream,Collection,Iterable, orIterator. - Each element in the returned sequence must be a
DynamicNode, most commonly aDynamicTestor aDynamicContainer.
The simplest implementation returns Stream<DynamicTest>. Each dynamic test requires a display name and an Executable lambda that contains the test logic. Because the executable can throw exceptions, standard JUnit assertions work directly inside it.
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
class BasicDynamicTestDemo {
@TestFactory
Stream<DynamicTest> arithmeticTests() {
return Stream.of(
dynamicTest("one plus one equals two", () -> assertEquals(2, 1 + 1)),
dynamicTest("two plus two equals four", () -> assertEquals(4, 2 + 2))
);
}
}
When this class runs, JUnit registers two separate tests. If the second assertion fails, the report shows that specific dynamic test as failed while the first one passes.
Lifecycle Integration with @BeforeEach and @AfterEach
Dynamic tests participate in the same extension model as static tests. Methods annotated with @BeforeEach and @AfterEach run before and after every individual dynamic test, not just once for the factory method. This makes it possible to set up fresh state for each generated case.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
class CalculatorDynamicTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@TestFactory
Stream<DynamicTest> additionCases() {
record Case(int a, int b, int expected) {}
return Stream.of(
new Case(1, 2, 3),
new Case(-1, 1, 0),
new Case(0, 0, 0)
).map(c -> dynamicTest(
"%d + %d should equal %d".formatted(c.a(), c.b(), c.expected()),
() -> assertEquals(c.expected(), calculator.add(c.a(), c.b()))
));
}
}
Here, each dynamic test receives a newly initialized Calculator instance. The display names are generated from the input values, making failure output easy to read in CI logs or IDE test runners.
Organizing Tests with DynamicContainer
As the number of dynamic tests grows, a flat list becomes hard to navigate. DynamicContainer solves this by grouping related tests into named containers, producing a hierarchical test tree. Containers can nest inside other containers, which is useful when tests naturally cluster by feature, module, or data source.
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.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 GroupedCalculatorTest {
private final Calculator calculator = new Calculator();
@TestFactory
Stream<DynamicNode> groupedOperations() {
return Stream.of(
dynamicContainer("addition", Stream.of(
dynamicTest("1 + 2 = 3", () -> assertEquals(3, calculator.add(1, 2))),
dynamicTest("5 + 7 = 12", () -> assertEquals(12, calculator.add(5, 7)))
)),
dynamicContainer("subtraction", Stream.of(
dynamicTest("10 - 4 = 6", () -> assertEquals(6, calculator.subtract(10, 4))),
dynamicTest("0 - 0 = 0", () -> assertEquals(0, calculator.subtract(0, 0)))
))
);
}
}
Test reports reflect this nesting. Users can expand or collapse containers in IDEs and build tools, and failures remain traceable to the exact leaf test inside a container.
Generating Tests from External Data
The real power of @TestFactory appears when test inputs come from outside the compiled code. A factory method can read JSON files, query a database, call a REST endpoint, or scan a directory before constructing DynamicTest instances. This removes the need to hardcode test data or maintain large parameterized source files.
For example, imagine a service that publishes configuration rules as JSON. A test factory can fetch that document, parse each rule, and emit one dynamic test per rule. If the remote service adds a new rule tomorrow, the test suite covers it automatically on the next run without any source change.
@TestFactory
Stream<DynamicTest> rulesFromConfiguration() {
List<Rule> rules = configurationClient.fetchRules();
return rules.stream()
.map(rule -> dynamicTest(
"rule %s behaves correctly".formatted(rule.id()),
() -> validateRule(rule)