Across a multi-module monorepo, how would you standardize wiring multiple test suites (unit, integration, functional) into the lifecycle so CI runs the right suites at the right stage?
answer
- convention plugin = single source of truth
- uniform suite/task names across modules
- stage CI: test → integrationTest → functionalTest
- choose which suites wire into check
- aggregate reports + build cache across modules
basics
~10 sPut suite declaration, targets configuration, and check.dependsOn(...) wiring in a shared convention plugin applied to every module, so each module consistently exposes the same suites and CI stages can invoke them by task name.
solid answer
~40 sCentralize the policy in a **convention plugin** (a `buildSrc` or `build-logic` precompiled script plugin). The plugin declares the standard suites (`integrationTest`, `functionalTest`), configures their `targets { all { testTask.configure { ... } } }` (platform, ordering via `shouldRunAfter`, parallelism), and wires lifecycle with `tasks.named("check") { dependsOn(...) }`. Applying that plugin to every module guarantees uniform task names so CI pipelines can run staged: fast `:module:test` everywhere, then `integrationTest` in a later stage, and `functionalTest` only on main. Decoupling *which* suite runs from *when CI invokes it* — by NOT auto-wiring slow suites into `check` and instead targeting tasks per stage — gives fail-fast feedback while keeping `gradle build` honest for full verification. The convention plugin is the single source of truth, so adding a new suite or changing parallelism is one edit.
code
kotlin · 9 lines// build-logic/.../katajob.test-suites.gradle.kts (applied to every module)
testing {
suites {
val integrationTest by registering(JvmTestSuite::class) {
targets { all { testTask.configure { shouldRunAfter("test") } } }
}
}
}
tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) }go deeper
Out of depth; at most know suites must be wired per module.
Recognize a convention/buildSrc plugin removes per-module duplication.
Design the convention plugin and decide which suites attach to check vs. run only in CI stages.
Own the org policy: staging strategy, aggregate reporting, build-cache leverage, and governance of test execution across the monorepo.
## The problem at scale In a monorepo, copy-pasting suite declarations and `check.dependsOn` wiring into every `build.gradle.kts` drifts: modules disagree on suite names, parallelism, and what `check` includes. CI then can't reliably address suites by task path. ## Convention plugin as the source of truth Put the policy in `build-logic` (or `buildSrc`) as a precompiled convention plugin: ```kotlin // build-logic/src/main/kotlin/katajob.test-suites.gradle.kts plugins { `java` } testing { suites { val test by getting(JvmTestSuite::class) { useJUnitJupiter() } val integrationTest by registering(JvmTestSuite::class) { targets { all { testTask.configure { shouldRunAfter(test) maxParallelForks = 2 } } } } } } tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) } ``` Every module applies `id("katajob.test-suites")`. Now `:moduleA:integrationTest` and `:moduleB:integrationTest` exist with identical configuration. ## Staging in CI Because task names are uniform, the pipeline can stage execution: - **Stage 1 (every push):** `gradle test` — fast unit feedback, fail fast. - **Stage 2:** `gradle integrationTest` — heavier, possibly with services. - **Stage 3 (main/merge):** `gradle functionalTest` — slowest, end-to-end. Key design choice: whether a slow suite is wired into `check`. Wiring `integrationTest` into `check` means `gradle build` (and any `check` run) always pays for it — good for local full verification, but you may *not* wire `functionalTest` into `check` so it only runs when explicitly invoked in the right CI stage. The convention plugin makes this policy explicit and consistent. ## Cross-cutting concerns - **Toolchain/JVM** belongs to the suite (out of scope here) but the plugin can enforce it centrally. - **Reporting:** an aggregate `testReport`/`testCodeCoverageReport` task can roll up all suites across modules. - **Cacheability:** Test tasks are cacheable by default; keep inputs declared so the build cache skips unchanged suites across modules. - **Governance:** the convention plugin is reviewable in one place, so security/perf policies (heap, forks, system props) are uniform. ## Anti-patterns to avoid - Per-module ad-hoc wiring that drifts. - Auto-wiring every slow suite into `check`, making `build` painfully slow everywhere. - Hardcoding task-name strings in CI that don't exist uniformly across modules.
- Why might you wire `integrationTest` into `check` but NOT `functionalTest`?Integration tests are part of routine local verification, while functional/e2e suites are slow and best run only in a dedicated CI stage, so leaving them off `check` keeps `build` fast.
- How does uniform task naming via a convention plugin help CI?Pipelines can address `:any-module:integrationTest` consistently and stage execution by task path without per-module special-casing.
- How do you aggregate test results across many modules' suites?Use aggregating tasks (e.g. a root `TestReport` or `test-report-aggregation`/`jacoco-report-aggregation` plugin) that collect each suite's results.
saying these in an interview costs you the question
- Copy-pasting suite wiring into every module instead of a convention plugin.
- Auto-wiring slow e2e suites into `check`, making every `build` slow.
- Assuming CI can reliably target suites that are named differently per module.