skip to content

Design a multi-task test partitioning scheme so unit tests run fast by default and slow/integration tests run on demand, using filter blocks and tags.

level: seniorimportance: should knowfreq 32%

answer

  1. tag slow; test excludeTags(slow)
  2. register<Test>(integrationTest) includeTags
  3. shouldRunAfter not dependsOn
  4. Test is @CacheableTask
  5. check dependsOn integrationTest (CI)

basics

~10 s

Tag slow tests @Tag("slow"). Make the default test task excludeTags("slow"). Register a separate integrationTest Test task that includeTags("slow"), give it its own classpath, and wire check to depend on it in CI.

solid answer

~40 s

Categorize with `@Tag` (slow, db, integration). Configure the default `test` task to `useJUnitPlatform { excludeTags("slow") }` so the inner dev loop stays fast. Register an extra `Test` task for the heavy set: ```kotlin tasks.register<Test>("integrationTest") { useJUnitPlatform { includeTags("slow") } shouldRunAfter(tasks.test) } ``` Keep both **incremental and cacheable** — `Test` is `@CacheableTask`, so unchanged inputs skip re-execution. Wire `tasks.check { dependsOn("integrationTest") }` only where you want it in the gate (often CI-only). Use `shouldRunAfter`, not `dependsOn`, between them so failures surface in order without coupling. Optionally narrow each task further with `filter { includeTestsMatching(...) }` per package. The key wins: fast feedback locally, full coverage in CI, and clean caching because each task has distinct, declared inputs.

code

kotlin · 11 lines
kotlin
tasks.test { useJUnitPlatform { excludeTags("slow") } }

val integrationTest = tasks.register<Test>("integrationTest") {
    group = "verification"
    useJUnitPlatform { includeTags("slow") }
    testClassesDirs = sourceSets["test"].output.classesDirs
    classpath = sourceSets["test"].runtimeClasspath
    shouldRunAfter(tasks.test)
}

tasks.named("check") { dependsOn(integrationTest) }

go deeper

for a junior

Recognize the idea: a fast default test and a separate slow task.

for a middle

Configure both tasks with tags and wire check; set classpath/testClassesDirs correctly.

for a senior

Justify ordering relations vs dependencies, caching implications, and name+tag intersection scoping.

for a principal

Define the org-wide partitioning + tag taxonomy, CI stage mapping, and the source-set-vs-tag decision as a documented standard.

## Goal A fast inner loop (run unit tests on every save) plus a slower, complete suite (integration/db/e2e) on demand or in CI. The mechanism is **multiple `Test` tasks**, each filtered by tag and/or name. ## Step 1 — categorize with tags ```kotlin @Tag("slow") @Tag("db") class OrderRepositoryIT { /* ... */ } ``` Tags are semantic and survive renames (preferred over `*IT` name patterns for stable staging). ## Step 2 — keep the default task fast ```kotlin tasks.test { useJUnitPlatform { excludeTags("slow") } } ``` Now `./gradlew test` runs only fast tests. ## Step 3 — register the heavy task ```kotlin val integrationTest = tasks.register<Test>("integrationTest") { description = "Runs @Tag(\"slow\") integration tests" group = "verification" useJUnitPlatform { includeTags("slow") } testClassesDirs = sourceSets["test"].output.classesDirs classpath = sourceSets["test"].runtimeClasspath shouldRunAfter(tasks.test) } ``` `shouldRunAfter` orders them when both are requested without making integration depend on unit (a unit failure shouldn't be a hard prerequisite, but ordering keeps logs sane). For a stronger guarantee use `mustRunAfter`. ## Step 4 — wire into the gate selectively ```kotlin tasks.named("check") { dependsOn(integrationTest) // include in full verification } ``` If the inner loop must stay light, gate `integrationTest` behind a property or only attach it in CI. ## Step 5 — preserve caching and parallelism `Test` is `@CacheableTask`; with the build cache enabled, an unchanged module's tests are pulled from cache. Because each `Test` task declares its own inputs (classpath, filters), they cache independently. Use `maxParallelForks` to fan out within a task, and Gradle's task parallelism (with `--parallel` / Configuration Cache) across modules. ## Step 6 — combine name + tag where useful ```kotlin tasks.named<Test>("integrationTest") { filter { includeTestsMatching("com.acme.integration.*") } } ``` The name filter and tag filter intersect, scoping the task to a package *and* a tag. ## Pitfalls - Don't make integration `dependsOn(test)` if you want to run it alone; use ordering relations. - Sharing the same `testClassesDirs`/`classpath` across tasks is fine and avoids duplicate compilation. - A separate **source set** (e.g. `integrationTest`) is an alternative when integration tests need different dependencies/classpath; the tag approach is lighter when they share the same source set.

  • Why `shouldRunAfter` instead of `dependsOn` between the two test tasks?
    `dependsOn` forces unit tests to run (and pass) before integration even when you request only integration. `shouldRunAfter`/`mustRunAfter` order them when both are requested, without coupling.
  • How does this scheme stay compatible with the build cache?
    `Test` is `@CacheableTask`; each task declares its own inputs (classpath, filters), so unchanged modules' tests are restored from cache independently.
  • When would you use a separate source set instead of just a tag?
    When integration tests need a different classpath/dependencies; a dedicated `integrationTest` source set isolates them, whereas tags reuse the existing `test` source set.

saying these in an interview costs you the question

  • Using `dependsOn(test)` for integration when you also want to run it standalone.
  • Filtering slow tests only by name (`*IT`) and calling it a stable CI strategy.
  • Forgetting to set `testClassesDirs`/`classpath` on a hand-registered `Test` task, so it discovers nothing.

context