skip to content

What is a @TestFactory method in JUnit 5, and how does its execution differ from a plain @Test method?

level: middleimportance: must knowfreq 35%

answer

  1. factory, not a test — invoked at execution time
  2. cases absent from the discovery-phase plan
  3. must not be private or static
  4. returns DynamicNode shapes only
  5. empty result = zero tests, green build

basics

~20 s

A @TestFactory method is not a test; it is a factory invoked at runtime that returns dynamic test nodes, which JUnit then executes. Cases are produced during execution rather than fixed at discovery, so the number and names of tests can depend on runtime data.

solid answer

~50 s

`@Test` marks a test case: the platform finds it during **discovery**, so the test tree is known before anything runs. `@TestFactory` marks a **factory method**: it is itself invoked at execution time, and whatever `DynamicNode` objects it returns become the tests that run underneath it. That difference has real consequences. The generated cases do not exist in the discovery-phase test plan, so an IDE cannot list them before the run; their count and names can vary from run to run because they are computed from runtime data (rows in a file, entries returned by a service, a directory listing). Each generated case gets its own executable, its own display name, and reports pass/fail independently. A `@TestFactory` method must not be `private` or `static`, and its return type is restricted to `DynamicNode` shapes — a single node, or a `Stream`, `Collection`, `Iterable`, `Iterator`, or array of them. Anything else is rejected at runtime, not by the compiler.

code

java · 8 lines
java
@TestFactory
Stream<DynamicTest> palindromesFromConfiguredWords() {
    List<String> words = wordSource.load();   // runtime data
    return words.stream()
            .map(word -> DynamicTest.dynamicTest(
                    "is palindrome: " + word,
                    () -> assertTrue(isPalindrome(word))));
}

go deeper

for a junior

Be able to say it is a method that produces tests at runtime instead of being a single test, and that it returns dynamic test nodes.

for a middle

Add the discovery-vs-execution distinction, the non-private/non-static rule, and the restricted return types.

for a senior

Discuss what you lose — pre-run visibility, per-case lifecycle, stable identity — and the silent-empty-result hazard in CI.

for a principal

Position it as a deliberate tradeoff between data-driven coverage and tooling fidelity, with guardrails such as asserting a minimum generated case count.

## Discovery versus execution The JUnit Platform runs in two phases. **Discovery** builds a tree of test descriptors from selectors (a class, a package, a unique ID); **execution** walks that tree and runs it. A `@Test` method is resolved during discovery, so the IDE can show it, a filter can select it, and the total test count is known before the first assertion runs. `@TestFactory` deliberately breaks that. During discovery the platform sees only the factory method itself as a **container**. At execution time it invokes the method, takes the `DynamicNode` objects it returns, and registers them as children on the fly, running each one as it is produced. The generated cases therefore appear in the results but never appeared in the pre-run plan. ## What the method must look like ```java @TestFactory Stream<DynamicNode> generated() { ... } ``` Rules Jupiter enforces: - the method must **not be `private`** and must **not be `static`** (same as `@Test`); - it may declare parameters, which are resolved by `ParameterResolver` extensions exactly like a `@Test` method's parameters (`TestInfo`, `TestReporter`, injected fixtures); - the **return type is restricted** to `DynamicNode` or a `Stream`/`Collection`/`Iterable`/`Iterator`/array of `DynamicNode`. A wrong return type is a runtime `JUnitException`, because the constraint cannot be expressed in the annotation's Java type. `DynamicNode` is the abstract parent of the two node kinds the factory can emit — a leaf case and a grouping container — so a factory can return a flat list of cases or a small tree of them. ## Why it exists The motivating scenario is *the set of cases is not known when you write the code*. Examples: - one test per `.json` fixture file in a directory, so dropping in a new file adds a case with no code change; - one test per implementation discovered through `ServiceLoader`, so every registered implementation is contract-tested; - one test per row returned by a query or per entry in a downloaded spec; - a matrix built by combining two runtime-derived collections. All of these need to be computed while the JVM is running, from data that the discovery phase cannot see. ## What you give up compared with @Test 1. **Pre-run visibility.** The IDE cannot list the cases before running, so you cannot click a single generated case to run it in isolation; you rerun the factory and it regenerates everything. 2. **Per-case lifecycle.** `@BeforeEach`/`@AfterEach` wrap the **factory method**, not the individual generated cases. Extension callbacks tied to test methods do not fire for each dynamic case either. 3. **Stable identity.** Unique IDs of dynamic cases are index-based within the factory, so inserting a case shifts the identity of later ones — awkward for flaky-test history and for rerun-failed workflows. 4. **Silent emptiness.** If the factory returns an empty stream, zero tests run and nothing fails. A fixture directory that failed to be copied into the build output produces a green build with no coverage — the single most dangerous failure mode of this feature. ## What it is not It is not a replacement for running the same test body over a fixed list of inputs — that is what parameterized tests are for, and they keep discovery-time visibility. Reach for `@TestFactory` when the *cases themselves*, not just their arguments, are computed at runtime, or when different cases need genuinely different executable bodies. ## Reporting Each generated case reports independently: it has a display name supplied by the factory and its own pass/fail/duration. Containers group them in the tree. A failure names the generated case, so a good display name ("validates fixture invoice-2023-11.json") is essential — otherwise you get "test 7" in CI and have to guess. ## Summary for an interview "`@Test` is a case fixed at discovery; `@TestFactory` is a method run at execution time that produces cases from runtime data. You gain data-driven case generation and lose pre-run visibility, per-case lifecycle callbacks, and stable identity — and an empty result silently passes."

  • Why can the IDE not show the generated cases before you run the factory?
    Because they do not exist during the discovery phase. The platform only registers the factory method as a container at discovery time; the cases are created when the method is invoked during execution. That is also why their count can differ between runs and why you cannot select a single generated case with a pre-run filter.
  • Can a @TestFactory method be static, and can it take parameters?
    It must not be static (nor private) — the same restriction as @Test, because it needs a test instance. It may declare parameters, which are resolved by ParameterResolver extensions just like on a @Test method, so you can inject TestInfo, TestReporter, or fixtures provided by a registered extension.
  • Does a @TestFactory method work inside a @Nested class?
    Yes. It behaves like any other test-bearing method in that container: the enclosing lifecycle callbacks run around the factory invocation, and the generated cases appear beneath it in the tree. The lifecycle still wraps the factory as a whole rather than each generated case.

@Test is a printed exam paper handed out before the class starts; @TestFactory is an examiner who writes the questions on the spot from whatever material is on the desk that day.

saying these in an interview costs you the question

  • Calling @TestFactory 'a test that runs several times' rather than a factory producing test nodes
  • Assuming the generated cases appear in the discovery-time test plan and can be selected individually beforehand
  • Thinking the method may be static because it feels like a utility
  • Expecting @BeforeEach to run before each generated case
  • Not knowing that an empty returned stream silently produces a passing run with zero tests

context