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?
answer
- DynamicTest.dynamicTest(name, Executable)
- Executable.execute() throws Throwable
- DynamicNode = DynamicTest | DynamicContainer
- Created at runtime, not at discovery
- No annotations on a dynamic test
basics
~20 sA 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 linesimport 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
Recall the factory call, the two arguments, and that these tests are created while the suite runs rather than found by annotation scanning.
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.
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.
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.