skip to content

In a JUnit 5 project, junit-jupiter-api is normally a compile-scope test dependency while junit-jupiter-engine is runtime-only. Why is the API split from the engine at all, and what does the aggregate junit-jupiter artifact contain?

level: middleimportance: should knowfreq 42%

answer

  1. API = write against; engine = run with
  2. ServiceLoader finds TestEngine at runtime
  3. No test code imports engine internals
  4. junit-jupiter = api + params + engine
  5. platform-engine = SPI, platform-launcher = client API

basics

~20 s

Test code compiles only against the API (annotations, assertions, extensions); the engine is an implementation found at runtime through ServiceLoader. Keeping the engine off the compile path stops code depending on engine internals. The aggregate junit-jupiter artifact bundles api plus params, with the engine at runtime.

solid answer

~50 s

The split is the usual API/implementation separation applied to a test framework. - `junit-jupiter-api` is what your test **source** needs: `@Test`, lifecycle annotations, `Assertions`, `Assumptions`, and the `Extension` interfaces. It is compile-visible to test code. - `junit-jupiter-engine` is an **implementation** of the Platform's `TestEngine` SPI. Nothing in your test source references it by type; the Platform finds it at runtime via `ServiceLoader`. Keeping it off the compile classpath means nobody can accidentally import engine internals such as descriptor or execution classes, and it keeps the API's compatibility promise separate from the engine's. The same shape appears one layer down: `junit-platform-engine` is the SPI engines implement, and `junit-platform-launcher` is the client API that tools use. The aggregate `junit-jupiter` artifact exists so you don't juggle three coordinates: it brings `junit-jupiter-api` and `junit-jupiter-params` for compilation and `junit-jupiter-engine` for runtime. One dependency, correct scopes.

code

java · 18 lines
java
import org.junit.jupiter.api.Test;                 // junit-jupiter-api
import org.junit.jupiter.params.ParameterizedTest;  // junit-jupiter-params
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;

class PrimesTest {

    @Test
    void twoIsPrime() {
        assertTrue(Primes.isPrime(2));
    }

    @ParameterizedTest
    @ValueSource(ints = {3, 5, 7})
    void oddPrimes(int n) {
        assertTrue(Primes.isPrime(n));
    }
}

go deeper

for a junior

Know that you import from the api artifact and that a separate engine artifact actually runs the tests.

for a middle

Explain ServiceLoader discovery and why keeping the engine off the compile path prevents depending on internals; name what the aggregate bundles.

for a senior

Generalise to API/SPI/implementation layering and the different compatibility promises each artifact carries.

for a principal

Talk about governing this across many repos — one aggregate coordinate plus aligned versions, and a rule that test utilities never import engine or launcher internals.

## The layering JUnit 5 is deliberately organised as *contract* artifacts and *implementation* artifacts, at two levels. **Level 1 — the Platform.** - `junit-platform-engine` is the SPI: `TestEngine`, `TestDescriptor`, `UniqueId`, `EngineDiscoveryRequest`, `ExecutionRequest`. Implemented by engine authors. - `junit-platform-launcher` is the client API: `Launcher`, `LauncherDiscoveryRequest`, `TestPlan`, `TestExecutionListener`. Consumed by IDEs, build tools, and anyone driving tests programmatically. **Level 2 — Jupiter.** - `junit-jupiter-api` is the contract test authors write against. - `junit-jupiter-params` adds `@ParameterizedTest` and its argument sources (also author-facing). - `junit-jupiter-engine` implements `TestEngine` for Jupiter — it scans for Jupiter annotations, builds the descriptor tree, and runs it with the extension model. ## Why the API is separated from the engine **1. Test code has no legitimate reason to reference the engine.** Your test says `@Test` and `assertEquals`. Everything else — how a method is discovered, how a lifecycle callback is ordered, how a `TestDescriptor` is built — is engine business. Putting the engine on the compile classpath invites imports of `org.junit.jupiter.engine.*` internals, which the project does not promise to keep stable. Off the compile path, that mistake fails to compile. **2. Different compatibility promises.** The API evolves conservatively — it is what thousands of test files depend on. The engine is free to change internals between versions. Separate artifacts let each carry its own promise. **3. Runtime discovery, not compile-time coupling.** The engine is registered as a `ServiceLoader` service (`META-INF/services/org.junit.platform.engine.TestEngine`). The Platform enumerates that service on the classpath at run time. Nothing links the two by type. That is the same mechanism that lets a Cucumber or ArchUnit engine join a run without any code referring to it. **4. Substitutability.** Because the coupling is at the SPI, alternate or additional engines can execute alongside Jupiter's, and tooling never has to be recompiled. ## What actually goes wrong when scopes are muddled The informative case is conceptual, not a build recipe. If a project exposes only the API and no engine implementation ever reaches the runtime classpath, the Platform finds no engine capable of claiming those classes — the tests are simply not discovered, because the annotations are inert data without an engine to interpret them. Conversely, if the engine is compile-visible, nothing breaks immediately; the risk is a slow leak of internal types into test utilities that then breaks on a routine version bump. A third artifact matters for programmatic use: `junit-platform-launcher`. Test *code* never needs it, since only the harness calls the `Launcher` — an IDE or build tool supplies it. Code that drives the Launcher itself (a custom runner, a test-selection tool) does need it compile-visible. ## The aggregate `junit-jupiter` artifact Declaring three coordinates with three scopes is easy to get wrong, so the project ships an aggregate: `org.junit.jupiter:junit-jupiter`. It contains no code of its own — it is a pom that depends on: - `junit-jupiter-api` (compile), - `junit-jupiter-params` (compile), - `junit-jupiter-engine` (runtime). One declaration then gives you the writing surface plus the executing engine, with the scopes already correct. That is why modern documentation shows a single `junit-jupiter` dependency where older guides showed api + engine separately. ## Version alignment The artifacts must be version-consistent as a set: the Jupiter engine expects an API of the same generation, and both expect a compatible Platform. In the JUnit 5 line the numbers differ deliberately (Platform `1.x` vs Jupiter `5.x`), which is precisely why the project publishes a bill-of-materials to align them. JUnit 6.0 unified the numbering at `6.x` across Platform, Jupiter and Vintage, removing that source of confusion. ## The short version to say out loud "API is what I compile against; the engine is an implementation the Platform loads via `ServiceLoader` at runtime; keeping them separate stops test code binding to internals and lets engines be swapped or added. `junit-jupiter` is the convenience aggregate of api + params + engine."

  • How does the Platform find the Jupiter engine if no code references it?
    Through Java's `ServiceLoader`. The engine jar contains `META-INF/services/org.junit.platform.engine.TestEngine` naming its implementation class, and the Platform enumerates that service on the classpath (or module path) at launch. The same mechanism registers vintage, Cucumber, ArchUnit and any other engine.
  • Does test code ever need junit-platform-launcher at compile time?
    Only if the code itself drives the Launcher — a custom runner, a test-selection tool, or an IDE-like harness. Ordinary test classes never mention `Launcher` types; the launcher is supplied by whatever executes the tests, so it belongs on the runtime side for normal projects.

JDBC: you code against the java.sql interfaces and drop the driver jar on the runtime classpath; the driver is found by service discovery, never imported by name.

saying these in an interview costs you the question

  • Saying the engine must be compile-visible for @Test to work
  • Thinking junit-jupiter-api can execute tests by itself
  • Claiming the aggregate junit-jupiter artifact contains its own code
  • Believing Platform 1.x with Jupiter 5.x is a version mismatch
  • Confusing junit-platform-engine (SPI) with junit-platform-launcher (client API)

context