skip to content

What is the difference between depending on org.junit.jupiter:junit-jupiter versus the individual junit-jupiter-api / junit-jupiter-engine artifacts, and where do they belong in Gradle configurations?

level: middleimportance: must knowfreq 62%

answer

  1. aggregator = api + params + engine
  2. API compiles → testImplementation
  3. engine runtime → testRuntimeOnly
  4. you never import the engine
  5. platform-launcher also runtimeOnly

basics

~10 s

junit-jupiter is an aggregator that pulls in the API, params, and engine together. The API is what your tests compile against; the engine is runtime-only. Add the aggregator to testImplementation to cover both.

solid answer

~40 s

`org.junit.jupiter:junit-jupiter` is a convenience aggregator POM that transitively depends on `junit-jupiter-api`, `junit-jupiter-params`, and `junit-jupiter-engine`. Your test source compiles against the **API** (annotations like `@Test`, `Assertions`), so the API must be on the compile classpath — `testImplementation`. The **engine** is only needed at runtime for the Platform to discover and execute tests, so strictly it belongs on `testRuntimeOnly`. Using the single aggregator on `testImplementation` is the common, simplest choice: it puts everything on both the compile and runtime test classpaths and you don't have to reason about the split. If you want a minimal compile classpath you can split: `testImplementation("...junit-jupiter-api")` plus `testRuntimeOnly("...junit-jupiter-engine")`. Functionally the aggregator approach just works.

code

kotlin · 5 lines
kotlin
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

go deeper

for a junior

Know that junit-jupiter brings everything and goes on testImplementation.

for a middle

Articulate the api=compile / engine=runtimeOnly split and why the engine is never imported.

for a senior

Justify choosing the explicit split for a clean compile boundary vs the aggregator for simplicity.

for a principal

Standardize the mapping in a shared convention plugin and a version catalog so every module is consistent.

## Two separation axes JUnit 5 = JUnit Platform + Jupiter (the new programming model) + Vintage (JUnit 3/4 bridge). Within Jupiter there are distinct artifacts, and Gradle's configurations have distinct *visibility scopes*. Both matter. ### The Jupiter artifacts - `junit-jupiter-api` — the things you import in test code: `@Test`, `@BeforeEach`, `Assertions`, `Assumptions`. **Compile-time** dependency of your tests. - `junit-jupiter-params` — `@ParameterizedTest` and sources. Compile-time when you use it. - `junit-jupiter-engine` — the `TestEngine` implementation the Platform discovers via the `ServiceLoader`. **Runtime-only**; your code never imports it. - `junit-jupiter` — an aggregator that depends on all three. ### The Gradle configurations - `testImplementation` — on both the test **compile** and test **runtime** classpaths. - `testRuntimeOnly` — on the test runtime classpath only; invisible at compile time. - `testCompileOnly` — compile only, not runtime. ## The correct mapping ```kotlin dependencies { // Minimal, explicit split: testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") // The Platform launcher (often needed explicitly on newer Gradle): testRuntimeOnly("org.junit.platform:junit-platform-launcher") } ``` Or the one-liner that covers compile + runtime: ```kotlin dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } ``` ## Why care about the split? Putting the engine on `testRuntimeOnly` keeps it off your compile classpath, so you can't accidentally import engine-internal types and you get a cleaner compile boundary. The aggregator on `testImplementation` trades that purity for simplicity — fine for most projects.

  • Why is the engine runtimeOnly rather than implementation?
    Your test code never imports engine classes — only API classes. The engine is discovered at runtime via ServiceLoader by the Platform. Keeping it off the compile classpath prevents accidental coupling to engine internals.
  • What does the aggregator actually contain — code or just metadata?
    junit-jupiter is effectively a POM/module-metadata artifact with no real classes; it just declares dependencies on the api, params, and engine modules so one coordinate brings them all in.

saying these in an interview costs you the question

  • Putting the engine on testImplementation and importing engine internals.
  • Claiming the aggregator is a fat jar containing the code — it is dependency metadata.

context