Appearance
@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 at | Compile time | Runtime |
| Executable | Method reference | Lambda expression |
@BeforeEach / @AfterEach support | Yes | No (only @BeforeAll / @AfterAll) |
| Use case | Stable, known inputs | Dynamic, 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:
- Discovery phase — the engine scans the codebase and produces
TestDescriptorobjects. Each descriptor carries two critical things: a display name and an executable. - Execution phase — the platform takes each
TestDescriptorand 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
└── DynamicTestThink 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>orStream<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
| Scenario | Best 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.