skip to content

Can two different test engines — say the JUnit Jupiter engine and a Cucumber or ArchUnit engine — take part in the same JUnit Platform run? How does the platform keep their tests apart in the results?

level: seniorimportance: nice to knowfreq 28%

answer

  1. ServiceLoader broadcasts discovery to every engine
  2. UniqueId first segment = [engine:...]
  3. Engines merged into one TestPlan, executed one after another
  4. Duplicate engine ids = error
  5. Engine filters include/exclude by engine id

basics

~20 s

Yes. The Platform discovers every TestEngine on the classpath via ServiceLoader, asks each to discover tests, and merges the results into one TestPlan. Each engine roots its tests under its own unique-id segment, such as [engine:junit-jupiter], so identities never collide.

solid answer

~50 s

Yes — that is the point of the engine SPI. At launch the Platform enumerates all `TestEngine` services on the classpath, passes the same discovery request to each, and each returns a `TestDescriptor` tree rooted at its own id. Unique ids are namespaced by engine: `[engine:junit-jupiter]/[class:CartTest]/[method:total()]` versus `[engine:cucumber]/[feature:...]`, so two engines can never produce a colliding identity, and a report or a rerun can address exactly one test. The Platform then merges those roots into a single `TestPlan` and executes engine by engine, emitting one stream of events to listeners. The IDE or CI report shows one tree with engine-labelled branches. Two practical points: engine ids must be unique — two jars claiming the same id is an error — and you can restrict a run with engine filters (include/exclude by engine id) when you want, for example, only the architecture tests. Ordering across engines is not something to rely on.

code

java · 11 lines
java
// [engine:junit-jupiter]/[class:com.example.CartTest]/[method:total()]
// [engine:junit-vintage]/[runner:com.example.LegacyFileStoreTest]
// [engine:archunit]/[class:com.example.ArchRules]/[method:layersAreRespected]

listener = new TestExecutionListener() {
    @Override
    public void executionFinished(TestIdentifier id, TestExecutionResult result) {
        String engine = id.getUniqueIdObject().getSegments().get(0).getValue();
        System.out.println(engine + " -> " + id.getDisplayName() + " " + result.getStatus());
    }
};

go deeper

for a junior

It's enough to know several engines can run together and results appear in one report.

for a middle

Explain ServiceLoader discovery, the engine-rooted UniqueId, and the merged TestPlan.

for a senior

Add operational detail: duplicate-id errors, per-engine discovery failures, engine filters for slicing CI jobs, and discovery cost of unused engines.

for a principal

Use it to place cross-cutting concerns: reporting and session-scoped setup belong at the Platform listener layer precisely because it is the only engine-agnostic seam.

## Multiple engines are the normal case The JUnit Platform was designed so that "a test" is whatever an engine says it is. Discovery is a broadcast: the `Launcher` finds every `TestEngine` registered on the classpath through `ServiceLoader` (`META-INF/services/org.junit.platform.engine.TestEngine`) and gives each one the same discovery request. A single run commonly involves: - `junit-jupiter` — your Jupiter tests, - `junit-vintage` — legacy JUnit 4 tests, - `junit-platform-suite` — declarative suites, - third-party engines such as Cucumber's `cucumber-junit-platform-engine`, ArchUnit's JUnit 5 engine, jqwik, Spock 2.x, Kotest. Each engine interprets the request in its own terms. Given "select package `com.example`", Jupiter looks for annotated classes; Cucumber looks for feature resources under that package; ArchUnit looks for `@AnalyzeClasses` classes. An engine that finds nothing simply returns an empty root. ## How identities stay distinct: UniqueId Every node the Platform knows about carries a `UniqueId` — an ordered list of `type:value` segments. The **first segment is always the engine**: ``` [engine:junit-jupiter]/[class:com.example.CartTest]/[method:total()] [engine:junit-vintage]/[runner:com.example.LegacyTest] [engine:cucumber]/[feature:file:src/test/resources/checkout.feature]/[scenario:2] ``` Because every tree is rooted at its own engine segment, no two engines can produce the same id even if they both claim the same Java class. This is what makes "rerun this exact test" and stable report keys possible: `selectUniqueId(...)` addresses one node in one engine. Display names are *not* unique and are only for humans; ids are the machine identity. ## Merging into one TestPlan The `Launcher` collects the engine roots and assembles a single `TestPlan` — an immutable, hierarchical snapshot of `TestIdentifier`s. Listeners receive `testPlanExecutionStarted(plan)`, then per-node `executionStarted` / `executionSkipped` / `executionFinished` events, then `testPlanExecutionFinished`. From a listener's point of view there is one run; it can ask any identifier for its unique id and see which engine produced it. Execution proceeds engine by engine — the Platform does not interleave engines, and it does not run engines in parallel with one another (parallelism, when enabled, is an *intra*-engine concern, e.g. Jupiter's own parallel execution). The order in which engines run reflects service discovery and must not be relied on for correctness: two engines that fight over a shared resource is a design smell, not something to sequence around. ## Constraints and failure modes **Engine ids must be unique.** If two jars register engines with the same id, the Platform reports it as an error rather than silently picking one. This surfaces most often with shaded or duplicated engine jars. **An engine that throws during discovery does not kill the run.** The Platform records the failure against that engine's root so you see "engine X failed to discover" rather than losing every other engine's tests — important in CI, where a broken third-party engine would otherwise look like a total collapse. **Filtering by engine.** A discovery request can carry engine filters that include or exclude engines by id. That is how you say "run only the architecture engine" or "exclude vintage from this fast job". Post-discovery filters such as tag filters apply across engines, but only engines that expose tags participate meaningfully. **Classpath weight.** Every engine on the test runtime classpath participates in every discovery, so a rarely used engine still costs discovery time and can produce surprising extra nodes. ## Why this matters in a real project Once you internalise "the Platform merges engines", several things stop being mysterious: why Cucumber scenarios appear in the same IDE tree as unit tests; why a JUnit 4 test and a Jupiter test show up in one report; why an ArchUnit violation is reported as a failing "test". It also tells you where to intervene: cross-cutting concerns (reporting, timing, session-scoped setup) belong at the Platform listener level, because that is the only layer that sees all engines. Anything written as a Jupiter extension covers Jupiter tests only.

  • If one engine throws while discovering tests, what happens to the rest of the run?
    The Platform records the failure on that engine's root descriptor and continues with the other engines. You get a reported discovery error for the broken engine plus normal results everywhere else, rather than an aborted run — which is what you want in CI, where a third-party engine problem shouldn't hide your unit-test results.
  • Where would you implement timing/reporting that must cover Jupiter, vintage and Cucumber tests alike?
    At the Platform level, as a `TestExecutionListener`, because that is the only layer that sees the merged `TestPlan` and every engine's events. A Jupiter `Extension` would only observe Jupiter tests, and a JUnit 4 rule only vintage ones.

Several specialist search teams are handed the same search area; each reports finds tagged with its own team name, and headquarters merges the reports into one map.

saying these in an interview costs you the question

  • Believing only one engine can run at a time
  • Thinking display names identify tests uniquely
  • Assuming engines execute in parallel with each other
  • Expecting a Jupiter extension to observe Cucumber or vintage tests
  • Thinking a failing engine aborts the whole run

context