Across a large multi-module Gradle build, how would you guarantee every module activates the JUnit Platform consistently, and what are the trade-offs of the approaches?
answer
- convention plugin in build-logic / buildSrc
- useJUnitJupiter in shared suite
- single source of truth + version
- withType<Test>().configureEach alternative
- enforce every java module applies it
basics
~10 sPut useJUnitPlatform() (or the suite DSL useJUnitJupiter()) into a shared convention plugin applied by every module's build script, instead of repeating it. The convention centralizes the framework choice and its dependencies.
solid answer
~50 sRepeating `tasks.test { useJUnitPlatform() }` in dozens of `build.gradle.kts` files is fragile — one missed module silently runs zero or wrong tests. The standard solution is a **convention plugin** (a precompiled script plugin in `buildSrc` or an included `build-logic` build) that every module applies: ```kotlin // build-logic/src/main/kotlin/myorg.java-conventions.gradle.kts plugins { `java-library` } testing { suites { val test by getting(JvmTestSuite::class) { useJUnitJupiter("5.10.2") } } } ``` Then each module does `plugins { id("myorg.java-conventions") }`. This centralizes the selector, the Jupiter version, and the launcher dependency, so adding a module can't drift. Using the **suite DSL** is preferable to a raw `tasks.test { useJUnitPlatform() }` because the suite also wires dependencies and gives a single place to define extra suites (integration tests) uniformly. The trade-off: a `build-logic` included build adds a layer of indirection and an extra compilation, but it's the only scalable way to keep the framework choice consistent and versioned in one place.
code
kotlin · 13 lines// build-logic/src/main/kotlin/myorg.java-conventions.gradle.kts
plugins { `java-library` }
testing {
suites {
val test by getting(JvmTestSuite::class) {
useJUnitJupiter("5.10.2")
}
}
}
// each module:
// plugins { id("myorg.java-conventions") }go deeper
Likely just knows the per-module useJUnitPlatform() call; centralization is beyond scope.
Recognizes duplication is a problem and that convention plugins or configureEach can centralize the selector.
Designs a convention plugin with the suite DSL, centralizing framework, version, and shared test dependencies.
Frames it as governance: build-logic single source of truth, version management, enforcement that every java module applies it, and the cold-build trade-off.
## The scaling problem In a 50-module build, copy-pasting `useJUnitPlatform()` into every script means the framework activation is duplicated 50 times. The failure mode is insidious: a new module that forgets it doesn't error — it runs with the JUnit 4 default and reports zero Jupiter tests as success. You want **one** authoritative declaration. ## Convention plugins Gradle's recommended pattern is the **convention plugin**: a precompiled `*.gradle.kts` script plugin living in `buildSrc/` or, preferably for larger builds, a separate **included build** named `build-logic`. It encapsulates shared configuration — including test-framework activation — behind a plugin id each module applies. ```kotlin // build-logic/src/main/kotlin/myorg.java-conventions.gradle.kts plugins { `java-library` } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } testing { suites { val test by getting(JvmTestSuite::class) { useJUnitJupiter("5.10.2") dependencies { implementation("org.assertj:assertj-core:3.25.3") } } // an extra integration suite, uniform across all modules register<JvmTestSuite>("integrationTest") { useJUnitJupiter() targets.all { testTask.configure { shouldRunAfter(test) } } } } } ``` A module then just applies `id("myorg.java-conventions")` and inherits the Platform activation, version, common test deps, and a standardized integration-test suite. ## Suite DSL vs raw task config in the convention - **Suite DSL (`useJUnitJupiter()`)** — recommended. It activates the Platform *and* wires the Jupiter + launcher dependencies, and is the natural home for extra suites. One declaration covers framework + deps. - **Raw `tasks.withType<Test>().configureEach { useJUnitPlatform() }`** — works and even covers dynamically-created Test tasks, but you must declare Jupiter dependencies separately, and it doesn't model additional suites. ## Trade-offs and governance - **Pro:** single source of truth for framework + version; impossible for a module to drift; centralized upgrades (bump Jupiter once). - **Con:** `build-logic` adds an included build that compiles before the main build (slightly slower cold builds, extra indirection for newcomers). - **Enforcement:** you can add a build-time check (e.g. an init/verification task) asserting every `java`-applying project also applies the convention, catching modules that bypass it. ## Why this is a principal-level concern The decision isn't 'which method' — it's establishing the **governance** so the framework choice, its version, and its dependency wiring are declared once and enforced, rather than scattered and drift-prone across an evolving module graph.
- Why prefer the suite DSL over tasks.withType<Test>().configureEach { useJUnitPlatform() } in a convention plugin?The suite DSL activates the Platform and auto-wires Jupiter and the launcher dependencies, and is the idiomatic place to define extra suites uniformly. The configureEach approach activates the framework but leaves dependency declaration manual.
- What's a downside of putting conventions in a build-logic included build?It adds an extra build that must compile before the main build (slower cold builds) and is one more layer of indirection for newcomers, though it scales far better than buildSrc for large multi-module projects.
saying these in an interview costs you the question
- Recommending copy-paste of useJUnitPlatform() into every module instead of centralizing — it drifts and silently fails.
- Treating this as a syntax question rather than a governance/consistency decision.