A build compiles fine but at test time fails with errors from `junit-platform-launcher` (e.g. NoSuchMethodError / 'no tests found'). How does a JUnit version mismatch cause this, and how does the BOM prevent it?
answer
- launcher drives engines via TestEngine SPI
- compile needs only api; engine+launcher are runtime
- mismatch -> NoSuchMethodError / no tests found
- BOM aligns commons/launcher/engine
- inspect testRuntimeClasspath
basics
~20 sThe Jupiter engine and the platform launcher on the test classpath are different, incompatible versions, so the launcher can't load/run the engine. The BOM pins engine, api, and launcher to one compatible set, removing the mismatch.
solid answer
~40 sJUnit's launcher (`junit-platform-launcher`) discovers and drives **engines** (Jupiter, Vintage) through the Platform `TestEngine` SPI. That SPI evolves: a Jupiter `5.10` engine expects Platform `1.10` launcher/commons APIs. If something puts an older `junit-platform-launcher` (or `junit-platform-commons`) on `testRuntimeClasspath` while a newer Jupiter engine is present — common when versions are hand-pinned or a transitive drags one in — the launcher hits methods/classes that don't exist yet, throwing `NoSuchMethodError`/`NoClassDefFoundError`, or silently discovering no tests. Compilation succeeds because only `junit-jupiter-api` is needed to compile; the mismatch is a *runtime* classpath problem. The `junit-bom`, imported via `platform(...)`, applies one aligned version across api, engine, params, launcher, and commons, so the SPI versions line up. Since Gradle 8.x auto-adds a launcher matching the engine, the remaining job is just keeping the engine itself BOM-aligned.
code
bash · 3 lines# Find an off-version Platform module dragged in transitively
./gradlew dependencies --configuration testRuntimeClasspath \
| grep -E 'junit-platform|junit-jupiter'go deeper
Recognize that engine and launcher must be the same JUnit version and the BOM ensures that.
Explain compile vs runtime classpath, the TestEngine SPI contract, and the concrete error symptoms.
Diagnose via dependencyInsight, reason about transitive overrides, and weigh platform vs enforcedPlatform.
Establish org-wide policy so JUnit is always BOM-managed (catalog + convention plugin) and CI catches drift.
## The launcher/engine architecture The **JUnit Platform** defines a `TestEngine` SPI. The **launcher** (`junit-platform-launcher`) is the component a build tool calls; it scans the classpath for `TestEngine` implementations and asks each to *discover* and *execute* tests. **JUnit Jupiter** provides the `junit-jupiter-engine` `TestEngine`; **Vintage** provides `junit-vintage-engine`. All three engines plus the launcher share `junit-platform-commons`. The contract between launcher, commons, and an engine is the Platform version (the `1.x` line). An engine built against Platform `1.10` calls into commons/launcher classes and methods that exist only in `1.10`. ## Why a mismatch compiles but fails at runtime Your test *source* only needs `junit-jupiter-api` (annotations, `Assertions`). That is `compileTestClasspath`. The **engine** and **launcher** live on `testRuntimeClasspath` — they aren't referenced by your code at all, only loaded reflectively at run time. So the compiler is happy even when the runtime versions are incoherent. The failure surfaces only when the launcher actually runs: - `NoSuchMethodError` / `NoClassDefFoundError` from `org.junit.platform.*` — newer engine calling an older launcher/commons. - `LinkageError` during engine discovery. - "No tests found for given includes" / the test task reports zero tests — the launcher couldn't initialize the engine, so nothing was discovered. ## Typical ways the mismatch sneaks in 1. Hand-pinning each artifact and bumping Jupiter but not the launcher. 2. A transitive dependency (e.g. an old testing library) dragging in an older `junit-platform-launcher` or `commons`. 3. Mixing a Spring Boot / other BOM that manages JUnit with an explicit off-version override. ## How the BOM fixes it Importing `org.junit:junit-bom` via `platform(...)` puts an aligned version constraint on **every** JUnit module — api, engine, params, launcher, commons, vintage — so whichever ones appear (directly or transitively) collapse to one compatible set: ```kotlin dependencies { testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } ``` ## Gradle 8.x convenience Since Gradle 8.x, when you use JUnit Platform the build automatically adds a `junit-platform-launcher` to the test runtime classpath that matches the engine — so you may not even need the explicit `testRuntimeOnly` line. But if a transitive forces an older launcher, the BOM constraint is what keeps it aligned. Diagnose with `./gradlew dependencies --configuration testRuntimeClasspath` and look for two Platform versions.
- Why does the project compile even with a broken runtime version mix?Test compilation only needs junit-jupiter-api on compileTestClasspath. The engine and launcher are loaded reflectively at runtime from testRuntimeClasspath, so a version conflict there is invisible to the compiler and only fails when the launcher executes.
- How would you actually diagnose which dependency dragged in the wrong launcher version?Run `./gradlew dependencies --configuration testRuntimeClasspath` (or `dependencyInsight --dependency junit-platform-launcher`) and look for two different junit-platform versions and which module requested the older one.
- Does Gradle 8.x still need the explicit junit-platform-launcher runtime dependency?Often not — Gradle 8.x auto-adds a matching launcher for the platform. But declaring it (versionless, under the BOM) is harmless and makes the launcher explicit; the BOM keeps it aligned if a transitive otherwise wins.
saying these in an interview costs you the question
- Saying the fix is to delete the test cache or rerun — it's a classpath version problem.
- Claiming the compiler should have caught it — the engine/launcher are runtime-only.
- Blindly using enforcedPlatform to 'force' versions across all BOMs, which can break other managed deps.