The JUnit Platform's TestEngine interface splits work into a discovery phase and an execution phase. What does each phase produce, and why is discovery a separate step at all?
answer
- discover → TestDescriptor tree, no test code runs
- execute → events on an EngineExecutionListener
- UniqueId = [engine:x]/[class:y]/[method:z]
- Disabled = discovered then skipped
- Selectors during discovery; post-discovery filters after
basics
~20 sdiscover() inspects the request and returns a tree of TestDescriptors with unique ids, running no test code. execute() then runs that tree, reporting started/skipped/finished events. Separating them lets tools list, count, filter and address tests before anything executes.
solid answer
~50 s`TestEngine` has two operations. `discover(EngineDiscoveryRequest, UniqueId)` interprets selectors and discovery filters and returns a `TestDescriptor` tree rooted at that engine's unique id — a pure model of what exists, built without invoking any test body. `execute(ExecutionRequest)` receives that tree plus an `EngineExecutionListener` and configuration parameters, and runs it, calling `executionStarted`, `executionSkipped` and `executionFinished(TestExecutionResult)` for containers and tests. The separation buys a lot: - **Tooling.** IDEs draw the test tree, and build tools count and report tests, before running anything; `Launcher.discover()` is a pure dry run. - **Addressability.** Every node has a stable `UniqueId`, so "rerun exactly this" is expressible via `selectUniqueId`. - **Filtering.** Post-discovery filters (tags, for example) prune the tree between the phases. - **Fail-fast structure.** Structural problems surface as discovery errors rather than mysterious runtime behaviour. A `@Disabled` test still appears in discovery — it is discovered, then skipped during execution.
code
java · 8 linespublic interface TestEngine {
String getId();
TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId uniqueId);
void execute(ExecutionRequest request);
}go deeper
Know that JUnit first finds tests and then runs them, and that finding produces the tree your IDE shows.
Name the artifacts of each phase — TestDescriptor tree with unique ids, then execution events — and that discovery runs no test code.
Explain what the split enables: dry runs, stable rerun-by-id, tag filtering between phases, multi-engine merging, and discovery-time failure containment.
Use it as the leverage point for test infrastructure — change-based selection and flaky-test quarantine are built on the discovery model and stable ids, not on parsing runner output.
## The contract A `TestEngine` implementation exposes essentially three things: ```java String getId(); TestDescriptor discover(EngineDiscoveryRequest request, UniqueId uniqueId); void execute(ExecutionRequest request); ``` The engine is found by the Platform via `ServiceLoader`, so the launcher can call it without any compile-time reference. ## Phase 1 — discovery **Input:** an `EngineDiscoveryRequest` containing *selectors* (where to look: classpath roots, packages, classes, methods, unique ids, files), *discovery filters* (class-name and package-name predicates), and *configuration parameters*. **Output:** a `TestDescriptor` tree rooted at the engine's own `UniqueId`. Each descriptor carries: - a `UniqueId` — an ordered list of `type:value` segments, e.g. `[engine:junit-jupiter]/[class:com.example.CartTest]/[method:total()]`; - a **display name** for humans; - a **type** — TEST, CONTAINER, or CONTAINER_AND_TEST; - optional **tags** and a **TestSource** (class/method/file position) so an IDE can jump to it. The defining property: discovery **runs no test code**. It reflects over classes and resources and builds a model. That is why an IDE can populate a test tree instantly, and why a build tool can say "42 tests" before executing the first one. **Discovery-time failures** are represented in the tree rather than thrown — an engine that cannot make sense of its input yields a descriptor marked as failed, so one broken engine does not destroy the whole plan. ## Phase 2 — execution **Input:** an `ExecutionRequest` carrying the root `TestDescriptor` from phase 1, an `EngineExecutionListener`, and configuration parameters. **Output:** side effects plus a stream of events. For every node the engine reports exactly one of: - `executionStarted(descriptor)` … then `executionFinished(descriptor, TestExecutionResult)` where the result is SUCCESSFUL, FAILED (with a `Throwable`), or ABORTED (a failed assumption); or - `executionSkipped(descriptor, reason)` — never started at all, e.g. `@Disabled`. Containers report the same events, so a class with a failing `@BeforeAll` shows a failed container with skipped or unreported children. A node the engine decides to skip is still a node — it existed after discovery. This is why disabled tests appear in reports as skipped rather than vanishing. ## Why split the phases **1. Tooling needs a model before a run.** Test trees, counts, dry runs, "which tests would this change affect" analysis — all need the structure without side effects. `Launcher.discover(request)` is exactly this. **2. Stable addressing.** Unique ids let a tool rerun a single test deterministically (`selectUniqueId`), key historical results, and quarantine a flaky node. Without a discovery model, identity would be whatever a runner happened to print. **3. A place for cross-engine filtering.** Post-discovery filters — tag filters most notably — operate on the discovered tree, so the launcher can prune uniformly across engines. Selectors, by contrast, are interpreted *by* engines during discovery. **4. Aggregation across engines.** The launcher can only merge multiple engines' trees into one `TestPlan` because each engine hands over a complete structural model first. **5. Better failure semantics.** A malformed test class is a discovery-phase problem; conflating the phases would surface it as a strange mid-run error. ## What the launcher does with it `Launcher.discover(request)` converts descriptors into an immutable `TestPlan` of `TestIdentifier`s (a read-only projection — tools should not hold engine descriptors). `Launcher.execute(request)` performs discovery, applies post-discovery filters, then asks each engine to execute, translating engine-level events into `TestExecutionListener` callbacks on identifiers. ## Common misconceptions - *"Discovery runs `@BeforeAll`"* — no. No user test code runs during discovery. (Some parameterised argument providers are invoked at execution time, not discovery.) - *"Disabled tests are not discovered"* — they are; skipping happens in execution. - *"Discovery is cheap, so it can be repeated freely"* — reflection over a large classpath is measurable; that is why `execute(TestPlan)` exists to reuse a plan. - *"Filters and selectors are the same thing"* — selectors say where to look, filters remove from what was found. ## Saying it crisply "Discovery builds an addressable, side-effect-free tree of what exists; execution walks that tree and reports events. Splitting them is what makes IDE trees, dry runs, tag filtering, stable rerun-by-id and multi-engine merging possible."
- Is a `@Disabled` test discovered?Yes. Discovery builds the full structural model, so the disabled test appears as a node in the `TestPlan`. During execution the engine reports `executionSkipped` with the reason instead of starting it, which is why reports show it as skipped with a message rather than omitting it entirely.
- What is the difference between a FAILED and an ABORTED execution result?FAILED means an assertion or unexpected exception ended the test — a real defect signal. ABORTED means the test bailed out because a precondition was not met, typically a failed assumption (`Assumptions.assumeTrue`). Tools report aborted tests separately from failures precisely because they are not a red build signal.
- Why does the launcher expose `TestIdentifier` instead of the engine's `TestDescriptor`?`TestPlan`/`TestIdentifier` is an immutable, engine-agnostic projection of the descriptor tree. Handing tools the live descriptors would let them mutate engine state and couple them to engine internals; the identifier exposes only stable facts — unique id, display name, type, tags, source.
Discovery is surveying and mapping the building; execution is walking the mapped route. You can price, filter and reroute the map before anyone takes a step.
saying these in an interview costs you the question
- Claiming test code such as @BeforeAll runs during discovery
- Saying disabled tests are never discovered
- Treating unique ids as display names (or vice versa)
- Thinking a discovery failure in one engine aborts the whole run
- Confusing selectors (where to look) with filters (what to remove)