skip to content

DynamicTest & DynamicContainer

Building the dynamic test tree: dynamicTest lambdas nested inside dynamicContainer groups. The follow-up is always the lifecycle difference from regular @Test methods.

on this pageshow

questions

5

In JUnit 5, what is a DynamicTest object, how do you construct one, and how does it differ at runtime from a plain method annotated with @Test?

level: juniorimportance: must knowfreq 36%

answer

  1. DynamicTest.dynamicTest(name, Executable)
  2. Executable.execute() throws Throwable
  3. DynamicNode = DynamicTest | DynamicContainer
  4. Created at runtime, not at discovery
  5. No annotations on a dynamic test

basics

~20 s

A DynamicTest is a test case created at runtime as an object, via DynamicTest.dynamicTest(displayName, executable) — a display-name string plus a lambda holding the assertions. Unlike a @Test method it is not discovered by annotation scanning and carries no annotations of its own.

solid answer

~60 s

`DynamicTest` is a runtime test case represented as an **object** rather than an annotated method. You build one with the static factory: ```java DynamicTest.dynamicTest("rejects negative amount", () -> assertThrows(...)); ``` The first argument is the display name shown in the report; the second is an `Executable` — a functional interface whose `execute()` may throw any `Throwable`, so assertions and checked exceptions work naturally in the lambda. `DynamicTest` and `DynamicContainer` both extend `DynamicNode`, so a group of them forms a tree of test nodes. The key runtime difference: a `@Test` method exists at **discovery** time — JUnit scans annotations, builds the test plan, and IDEs and filters can see it before anything runs. A dynamic test only exists once the generating code has executed. Consequences follow from that: you cannot put annotations such as `@Disabled` or `@Tag` on a dynamic test, and name-based filters cannot select an individual one. What you gain is that the number, names and behaviour of the cases can be computed from data — files, database rows, a spec document.

code

java · 10 lines
java
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

@TestFactory
List<DynamicTest> palindromeCases() {
    return List.of(
        dynamicTest("racecar is a palindrome",
            () -> assertTrue(Strings.isPalindrome("racecar"))),
        dynamicTest("kotlin is not a palindrome",
            () -> assertFalse(Strings.isPalindrome("kotlin"))));
}

go deeper

for a junior

Recall the factory call, the two arguments, and that these tests are created while the suite runs rather than found by annotation scanning.

for a middle

Add the DynamicNode hierarchy, the Executable throws-Throwable detail, and the concrete trade-off: no annotations and no discovery-time visibility in exchange for data-driven cases.

for a senior

Emphasise reporting and diagnosis — independent pass/fail per case, discriminating display names, URI test sources for navigation — and when generated cases beat one big test method.

for a principal

Frame it as a choice about where the test set is defined: in code at discovery time versus in data at runtime, and the tooling costs (filtering, identity stability) that choice imposes on the pipeline.

## The two kinds of test in Jupiter JUnit Jupiter distinguishes **static** tests from **dynamic** ones. A static test is the familiar `@Test` method. It is found by scanning classes for annotations during the *discovery* phase, before any test code runs. The full test plan — every test's identity, name and tags — is known up front. That is what lets an IDE show the tree before executing, and what lets tag or name filters select a subset. A dynamic test is a plain Java object of type `org.junit.jupiter.api.DynamicTest`, produced by code while the suite is already running. Nothing about it exists at discovery time: the engine knows only that some generator will produce nodes, and the actual cases appear as they are generated. ## Constructing one ```java DynamicTest t = DynamicTest.dynamicTest("palindrome: racecar", () -> assertTrue(isPalindrome("racecar"))); ``` Two pieces: - **displayName** — a `String`, free-form, shown in reports and IDEs. There is no method name to fall back on, so this string *is* the test's identity for a human reader. Make it discriminating: `"case 3"` is useless in a failure report; `"rejects amount -5 with ILLEGAL_AMOUNT"` is not. - **executable** — an `org.junit.jupiter.api.function.Executable`, a functional interface with `void execute() throws Throwable`. Because it declares `Throwable`, you can call anything inside without wrapping checked exceptions, and JUnit's assertions work exactly as in a `@Test` method. A thrown `AssertionError` fails that dynamic test only; siblings still run. There is also an overload taking a `URI testSourceUri`, which tells the IDE where the test "lives" (a file, a class, a method), so double-clicking a failure can navigate somewhere sensible. Without it, navigation lands on the generating method. ## DynamicNode: the type hierarchy ``` DynamicNode (abstract) ├── DynamicTest — a leaf: display name + Executable └── DynamicContainer — a branch: display name + children (DynamicNodes) ``` Because containers hold nodes, and a node may itself be a container, generated tests can form an arbitrarily deep tree rather than one flat list. Both node types are created only through their static factory methods; they are immutable value-ish objects with no annotations, no lifecycle of their own, and no way to attach metadata. ## What you give up 1. **No annotations.** `@Disabled`, `@Tag`, `@Timeout`, `@RepeatedTest` and friends target methods; a `DynamicTest` is an object, so none of them apply. To disable a generated case you filter it out of the generator; to time-box it you use `assertTimeout` inside the executable. 2. **Not visible before execution.** Discovery-time filters (by tag, by test name pattern) select the *generating method*, all-or-nothing. You cannot ask a build to run only the 17th generated case by name. 3. **No per-case Jupiter lifecycle callbacks.** `@BeforeEach`/`@AfterEach` are bound to the generating method, not to each generated node. 4. **Index-based identity.** Unique IDs are positional (`#1`, `#2`, …), so inserting a case shifts the identity of later ones. ## What you gain The test *set itself* becomes data-driven at runtime. Concrete wins: - One case per file in a fixtures directory, discovered when the suite runs — add a file, get a test, with no code change. - One case per row returned by a query, or per entry in a specification document. - Cases whose count depends on the environment (every registered implementation of an interface, every enum constant, every endpoint in an OpenAPI document). - Named sub-checks inside a scenario, each reported and failing independently rather than the first `assertEquals` aborting the rest. That last point is worth stressing: replacing ten sequential assertions in one `@Test` with ten dynamic tests changes the report from "one red test" to "three red cases, seven green", which is far better diagnostic information. ## Runtime behaviour Each dynamic test is reported to the platform as its own test node: it gets start and finish events, appears separately in the IDE tree and in XML/HTML reports, and its failure does not stop siblings. So although Jupiter's *extension* callbacks do not fire per node, platform-level listeners do observe each one — which is exactly why IDEs can display them individually while `@BeforeEach` still runs only once. ## Minimal complete picture ```java @TestFactory Collection<DynamicTest> palindromes() { return List.of( dynamicTest("racecar is a palindrome", () -> assertTrue(isPalindrome("racecar"))), dynamicTest("kotlin is not a palindrome", () -> assertFalse(isPalindrome("kotlin")))); } ``` The generated objects are the tests; the surrounding method merely hands them to the engine.

  • Can you put @Disabled or @Tag on a single dynamic test?
    No. Those annotations apply to methods and classes, and a dynamic test is an object created at runtime, after discovery-time annotation processing is over. To exclude a case you filter it out in the generating code; to tag a group, tag the generating method, which tags all of its cases together.
  • How do you make a failure in a generated case navigable in the IDE?
    Use the overload of DynamicTest.dynamicTest that takes a URI test source — for example a file URI for the fixture that drove the case, or a `class:`/`method:` URI. Without it the IDE can only point at the generating method, which is unhelpful when a hundred cases came from a hundred files.

A @Test method is a printed exam question; a DynamicTest is a question generated on the spot from the day's data — real and gradeable, but it did not exist when the paper was typeset.

saying these in an interview costs you the question

  • Calling a dynamic test "a @Test created by reflection" — it is an object, not a method, and never carries annotations.
  • Believing a failing dynamic test aborts the remaining generated cases.
  • Thinking tag or name filters can select an individual generated case.
  • Giving every generated case the same display name, making failures unidentifiable.
  • Assuming the executable cannot throw checked exceptions — Executable.execute declares Throwable.

context

open as a page

A developer reports that in JUnit 5 their @BeforeEach method runs only once even though the factory method they wrote produced twenty executable test cases at runtime. Why does that happen, and how do you give each generated case its own fresh setup?

level: middleimportance: must knowfreq 32%

basics

~20 s

Dynamic tests are generated at runtime, so Jupiter's per-test callbacks bind to the generating method, not to each generated case: @BeforeEach and @AfterEach run once around the whole factory. Put setup and teardown inside each executable, typically via a shared wrapper helper.

open as a page

In JUnit 5, how do you generate one runtime test case per element of an input stream or iterator without hand-building a list of case objects, and what does the laziness of that stream buy you?

level: middleimportance: should knowfreq 24%

basics

~10 s

Use DynamicTest.stream(inputStreamOrIterator, displayNameGenerator, testExecutor): it maps each input to a case named by the generator and executed by the ThrowingConsumer. The stream is consumed lazily as cases run, and JUnit closes it afterwards.

open as a page

When JUnit 5 test-generating code produces dozens of runtime cases, how do you group them into a tree instead of one flat list, and how are those generated nodes identified in reports and IDEs?

level: seniorimportance: should knowfreq 20%

basics

~20 s

Wrap groups with DynamicContainer.dynamicContainer(displayName, children), where children are DynamicNodes — including further containers, so nesting is arbitrarily deep. Generated nodes get positional unique IDs like [dynamic-container:#1]/[dynamic-test:#2], and an optional URI test source drives IDE navigation.

open as a page

Your team wants to turn a directory of 400 JSON fixture files into individual JUnit 5 test cases generated at runtime, one per file. What operational trade-offs would you weigh before committing to that, and how would you keep the suite maintainable?

level: principalimportance: nice to knowfreq 16%

basics

~20 s

You gain a case per file with zero code churn as fixtures are added. You lose discovery-time visibility: no per-case tags or disabling, no name-based selection, positional unique IDs that break rerun-failed and flaky history, and no framework-managed per-case setup. Keep it by naming cases after files, sorting, supplying source URIs, and owning isolation explicitly.

open as a page