skip to content

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?

level: principalimportance: nice to knowfreq 18%

answer

  1. convention plugin = single source of truth
  2. uniform suite/task names across modules
  3. stage CI: test → integrationTest → functionalTest
  4. choose which suites wire into check
  5. aggregate reports + build cache across modules

basics

~10 s

Put 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 s

Centralize 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
kotlin
// 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

for a junior

Out of depth; at most know suites must be wired per module.

for a middle

Recognize a convention/buildSrc plugin removes per-module duplication.

for a senior

Design the convention plugin and decide which suites attach to check vs. run only in CI stages.

for a principal

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.

context