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?
answer
- aggregator = api + params + engine
- API compiles → testImplementation
- engine runtime → testRuntimeOnly
- you never import the engine
- platform-launcher also runtimeOnly
basics
~10 sjunit-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 linesdependencies {
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
Know that junit-jupiter brings everything and goes on testImplementation.
Articulate the api=compile / engine=runtimeOnly split and why the engine is never imported.
Justify choosing the explicit split for a clean compile boundary vs the aggregator for simplicity.
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.