skip to content

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