Chapter 22Lesson 03~195 minutes

Gradle Source Sets, JVM Test Suites, Test Filtering, Reporting, and Integration Testing: Configuration, Design Choices, and Tradeoffs

Choose deliberately between JVM Test Suites and manual source-set wiring, between focused local filters and full CI gates, and between parallelism and isolation.

Test architectureFilteringLifecycle gatesParallel forksInteroperability

Learning objectives

  • Choose between the incubating JVM Test Suite model and manual source-set/Test-task wiring based on compatibility policy.
  • Design one or multiple suites around purpose and environment boundaries rather than arbitrary naming.
  • Separate developer filtering from full CI verification coverage.
  • Tune maxParallelForks only after proving test isolation and measuring resource constraints.
  • Keep report identities unique and machine-consumable.
  • Use a decision table to justify a test architecture with observable effects.

1. Configuration choices change evidence topology

There is no single “best” test layout independent of organizational constraints. A small library may need one unit suite and one integration suite. A service may need contract, database, and end-to-end lanes with distinct credentials and runtime costs. The right design makes those differences visible in source sets, classpaths, task names, reports, and CI gates rather than hiding them behind conditionals inside one giant test task.

2. JVM Test Suite versus manual source-set wiring

The JVM Test Suite model is concise and purpose-built: it creates a source set, suite-scoped configurations, a target, and a Test task. It also keeps framework/dependency configuration near the suite. The tradeoff is API status: JvmTestSuite remains incubating in Gradle 9.7.1.

Gradle’s manual integration-test path uses mature building blocks: sourceSets, configurations, and a registered Test task. It is more verbose because you must wire compile/runtime classpaths and lifecycle relationships yourself. An organization with a policy against incubating APIs may reasonably prefer that path.

Choice Advantages Costs / evidence burden
JvmTestSuite Purpose-level model; synthesized configurations/task; concise framework setup. Incubating API; upgrade review required; additional suite still needs explicit project/check edges.
Manual source set + Test Uses long-established Java/Test primitives; fine-grained control. More wiring; easier to omit runtime classpath, task, or lifecycle edge.

Do not mix both styles for the same test type without a migration reason; duplicate tasks/report paths are harder to audit.

3. One suite versus multiple environment-specific suites

Split suites when the tests have meaningfully different dependencies, runtime requirements, permissions, expected duration, or ownership. “integrationTest” and “databaseTest” can be separate if one uses only process-local resources while the other requires a database fixture. A suite name should predict its classpath and operational cost.

Avoid creating one suite per tiny category merely to obtain labels. JUnit tags can express orthogonal test metadata such as slow, smoke, or contract. The suite defines a build/runtime boundary; tags define selection metadata within a test engine.

4. Filtering: debugging tool or governance policy?

Local --tests is transient and ideal for feedback. It should not become the hidden default of a CI job that claims full coverage. If CI intentionally runs a subset, name the job/suite accordingly and publish separate evidence. A “unit-smoke” job is honest; a job named “test” that silently selects three methods is not.

Tag filters are version-controlled and can be appropriate for durable lanes, but they also create a zero-test risk when tags are renamed. Preserve fail-on-no-match behavior or add an explicit test-count gate rather than disabling failures globally.

5. Parallel forks: throughput versus isolation

Test.maxParallelForks controls how many forked test processes a task may run concurrently. The Java-plugin default is one. Raising it can reduce wall-clock time when tests are CPU-light and isolated, but it multiplies JVM memory, database connections, ports, files, and other external resource pressure.

tasks.withType<Test>().configureEach {
    // Example only after isolation has been proven and measured.
    maxParallelForks = 2
}

forkEvery is a different control: it restarts the test process after N test classes. Very small values can be expensive. Neither option repairs stateful tests. First remove shared mutable state or allocate isolated fixtures, then measure.

6. Report identity and interoperability

Keep each task’s report output unique. CI systems commonly ingest JUnit-style XML, while developers use Gradle’s HTML. If multiple tasks overwrite one directory, the final files can make the run look smaller or cleaner than it was.

tasks.named<Test>("integrationTest") {
    reports {
        junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/integrationTest"))
        html.outputLocation.set(layout.buildDirectory.dir("reports/tests/integrationTest"))
    }
}

The explicit configuration above matches conventional task-specific paths and demonstrates the API. In a normal build, the defaults are already separate by task name, so avoid unnecessary customization.

7. Decision table

Scenario Recommended control Why / observable behavior
Small Java library, unit + local integration tests Built-in test + one integrationTest suite. Two classpaths/tasks/reports; check can prove both.
Policy forbids incubating APIs Manual intTest source set + registered Test task. More explicit wiring; uses mature primitives.
Developer debugging one method --tests. No source/configuration mutation; focused report for that invocation.
CI wants smoke and full lanes Separate named lane/task or reviewed tag policy; full gate remains explicit. Evidence states which subset ran.
Tests use a single shared local server port Keep serial or allocate per-fork port/fixture first. Parallelism must not create nondeterministic failures.
Many teams need reusable testing policy Convention plugin/build logic, not copy-pasted root subprojects {}. Policy is reviewable and upgradeable; Chapter 18 principles apply.

8. Worked scenario: a service with fast and environment tests

Suppose unit tests take 20 seconds and require no external state. Integration tests take two minutes and use a process-local HTTP server. Production deployment should require both, but pull-request authors often rerun one integration class while debugging.

  1. Keep unit tests in test.
  2. Create integrationTest with its own source/classpath and explicit project dependency.
  3. Make check depend on the integration suite.
  4. Use shouldRunAfter(test) to prefer early unit failures without creating the scheduling edge.
  5. Use --tests for local focused reruns.
  6. Keep maxParallelForks=1 until the local server fixture allocates independent ports; then benchmark two forks.
  7. Archive both XML directories in CI and display separate counts.

This design keeps maintainability and developer focus without weakening the production gate.

9. Keep neighboring systems separate

Concern Owner
Which JVM runs Gradle Wrapper/runtime JDK configuration; Chapter 15/23 boundary.
Which compiler/test JVM is selected Java toolchain/Test javaLauncher; Chapter 23 goes deeper.
Which artifact repository serves test libraries Gradle repository/dependency policy; Chapters 19–20.
Which CI job archives XML CI platform configuration, not the Gradle suite itself.
How a real database is provisioned Application/CI/test-fixture orchestration; not automatically Gradle’s responsibility.
IDE test runner behavior IDE integration; verify CI with the Wrapper, not only IDE green icons.

10. Summary and next bridge

Test architecture is a set of explicit boundaries, not a collection of file-name conventions. Lesson 4 deliberately breaks those boundaries to build a repeatable diagnostic sequence.

Knowledge check

Why might a team choose manual integration-test wiring in Gradle 9.7.1?

When should you create a separate suite instead of a tag?

Why is --tests usually a poor hidden CI default?

What must be true before increasing maxParallelForks?

Why avoid sharing one report directory between tasks?

Which mechanism schedules integration tests from check?

Official references and version notes

Version-sensitive behavior was rechecked against current Gradle and JUnit primary documentation on 2026-08-24. Mandatory labs use Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the Gradle runtime, Java 17 as the project target, JUnit 6.1.3, and an isolated GRADLE_USER_HOME. The JVM Test Suite API is explicitly labeled incubating. No hosted CI, database, cloud service, commercial repository, or paid test platform is required.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.