Gradle Source Sets, JVM Test Suites, Test Filtering, Reporting, and Integration Testing: Guided Hands-On Workflow and Core Operations
Build a disposable Java project with the built-in test suite plus an explicit integrationTest suite, then inspect filtering, reports, and lifecycle wiring.
Learning objectives
- Bootstrap the lab from a previously verified Gradle 9.7.1 Wrapper and isolate Gradle User Home.
- Run the built-in unit suite and inspect its task/result/report state before adding integration tests.
-
Declare an
integrationTestJVM Test Suite with an explicit production-project dependency and JUnit 6.1.3. - Use class/method and tag filtering without confusing focused runs with the full verification gate.
-
Prove that
checkschedules the integration suite only after an explicit lifecycle dependency. - Inspect report paths and clean up only disposable lab state.
1. Setup and preflight
Create the exercise beside a known-good bootstrap project from
Chapter 15. The mandatory path assumes the Wrapper files have
already been reviewed and pin Gradle 9.7.1. Do not invoke an
arbitrary system gradle binary just because it is on
PATH.
rm -rf gradle-testing-lab
mkdir gradle-testing-lab
cd gradle-testing-lab
# Copy the four verified Wrapper entry-point files/directories from a trusted
# Chapter 15 bootstrap into this disposable directory before continuing.
# Expected at minimum: gradlew, gradlew.bat, gradle/wrapper/*
export GRADLE_USER_HOME="$PWD/.gradle-user-home"
java -version
./gradlew --version
./gradlew tasks --group verification
Windows PowerShell uses
$env:GRADLE_USER_HOME = "$PWD/.gradle-user-home" and
.\gradlew.bat. Verify the Gradle/JVM lines before
writing project files.
2. Create the smallest production project
The project has one production method and one production resource. The resource gives us a later runtime-classpath probe without any network service.
rootProject.name = "gradle-testing-lab"
plugins {
`java-library`
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter("6.1.3")
}
}
}
package dev.academy.testing;
public final class GreetingService {
private GreetingService() {}
public static String greeting(String name) {
if (name == null || name.isBlank()) {
return "hello, stranger";
}
return "hello, " + name.strip();
}
}
academy-integration
Save the Java class as
src/main/java/dev/academy/testing/GreetingService.java
and the text as src/main/resources/academy-banner.txt.
The Gradle runtime JDK and Java toolchain are different controls:
this lab documents JDK 21 for Gradle, while the Java plugin requests
a Java 17 compiler/runtime toolchain for project tasks.
3. Add and run unit tests
package dev.academy.testing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class GreetingServiceTest {
@Test
@Tag("unit")
void greetsNamedUser() {
assertEquals("hello, Ada", GreetingService.greeting("Ada"));
}
@Test
@Tag("unit")
void handlesBlankName() {
assertEquals("hello, stranger", GreetingService.greeting(" "));
}
}
./gradlew clean test
./gradlew test --info
find build/test-results/test -type f -print | sort
find build/reports/tests/test -type f -print | sort | head
Save the test as
src/test/java/dev/academy/testing/GreetingServiceTest.java. The first run should compile main and test sources, then execute
:test. A repeat may report UP-TO-DATE when
inputs/outputs have not changed; that is a task-state result, not a
replacement for test-report evidence.
Inspect
build/test-results/test/TEST-dev.academy.testing.GreetingServiceTest.xml
for machine-readable counts and
build/reports/tests/test/index.html for the human
report.
4. Declare the integration suite
Now replace the build script’s testing block with the
full model below. The two crucial lines are
implementation(project()) and the explicit
check dependency.
plugins {
`java-library`
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter("6.1.3")
}
register<JvmTestSuite>("integrationTest") {
useJUnitJupiter("6.1.3")
dependencies {
// Additional suites do not automatically see production output.
implementation(project())
}
targets {
all {
testTask.configure {
shouldRunAfter(tasks.named("test"))
useJUnitPlatform {
includeTags("integration")
}
}
}
}
}
}
}
tasks.named("check") {
dependsOn(testing.suites.named("integrationTest"))
}
package dev.academy.testing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("integration")
class GreetingResourceIT {
@Test
void productionResourceIsOnIntegrationRuntimeClasspath() throws IOException {
try (InputStream in = getClass().getResourceAsStream("/academy-banner.txt")) {
assertNotNull(in, "production resource should be visible");
assertEquals("academy-integration", new String(in.readAllBytes(), StandardCharsets.UTF_8).trim());
}
}
}
Save the integration test as
src/integrationTest/java/dev/academy/testing/GreetingResourceIT.java. Naming the source directory after the suite follows the suite
convention. The @Tag("integration") marker is a JUnit
Platform tag; the target task is configured to include it.
5. Inspect synthesized configurations and tasks before running
./gradlew tasks --group verification
./gradlew dependencies --configuration integrationTestCompileClasspath
./gradlew dependencies --configuration integrationTestRuntimeClasspath
./gradlew help --task integrationTest
./gradlew check --dry-run
Expected topology includes compileIntegrationTestJava,
integrationTestClasses, and
integrationTest. The runtime classpath report should
show the current project dependency relationship. The dry run should
include both test and
integrationTest because check now depends
on the additional suite.
6. Execute the suite and verify distinct evidence
./gradlew clean check
printf '%s
' '--- unit XML ---'
find build/test-results/test -type f -name 'TEST-*.xml' -print | sort
printf '%s
' '--- integration XML ---'
find build/test-results/integrationTest -type f -name 'TEST-*.xml' -print | sort
printf '%s
' '--- HTML entry points ---'
ls -l build/reports/tests/test/index.html
ls -l build/reports/tests/integrationTest/index.html
The expected observation is two distinct task/report families. The
integration test proves the production resource is on its runtime
classpath because implementation(project()) supplies
the project output. If either result directory is absent after a
clean run, do not call the verification complete.
7. Focused filters without weakening the gate
Use --tests for an ad-hoc developer run:
./gradlew test --tests 'GreetingServiceTest.greetsNamedUser'
./gradlew integrationTest --tests '*GreetingResourceIT*'
# Deliberately wrong filter: current TestFilter defaults to fail on no match.
set +e
./gradlew test --tests '*NoSuchTest*' > no-match.log 2>&1
status=$?
set -e
printf 'exit=%s
' "$status"
tail -n 20 no-match.log
The nonzero no-match result protects against a typo that silently
runs nothing. Do not permanently set
filter.isFailOnNoMatchingTests = false merely to make
CI green. If the intent is “zero tests is acceptable,” document why
and use a separately reviewed policy.
Also distinguish name filtering from the suite’s JUnit tag rule. The
tag limits what JUnit Platform discovers inside
integrationTest; --tests narrows the
Gradle task’s selected class/method names.
8. Before/after causality table
| Action | State read/changed | Independent verification |
|---|---|---|
test |
Reads test source, test compile/runtime classpaths; writes test task outputs/reports. | Unit XML/HTML plus task outcome. |
Register integrationTest |
Changes build model: new source set, configurations, target, Test task. |
tasks, help --task, configuration
reports.
|
implementation(project()) |
Adds project output/dependency edge to suite classpaths. |
integrationTestRuntimeClasspath and resource
test.
|
check.dependsOn(...) |
Changes verification task graph. |
check --dry-run and clean
check execution.
|
--tests |
Narrows one Test-task invocation; no source/configuration mutation. | Console selected test + resulting XML count. |
9. Challenge: which control belongs where?
A teammate asks for “smoke integration tests locally, all integration tests in CI.” Choose the controls before reading the answer.
Reveal one defensible design
Keep one explicit integrationTest suite and its full
check gate in version control. Developers can use
--tests for class/method focus. If “smoke” is a
durable semantic subset, use JUnit tags with a separate
intentional task/suite or a reviewed project property that changes
tag selection; do not globally filter the only CI integration task
down to smoke tests.
10. Cleanup
cd ..
rm -rf gradle-testing-lab
This removes only the disposable project and its isolated Gradle
User Home. It does not touch ~/.gradle.
11. Summary and next bridge
You now have independently inspectable unit and integration evidence. Lesson 3 turns those mechanics into architecture choices: suite API versus manual wiring, suite granularity, filters, reports, and test parallelism.
Knowledge check
Why does integrationTest declare
implementation(project())?
Additional JVM test suites do not automatically inherit access to production output/dependencies; the explicit project dependency supplies that relationship.
What proves check includes integration tests
before executing them?
A task-graph inspection such as check --dry-run,
followed by fresh task/report evidence from a clean run.
What should a typo in --tests do by
default?
Fail when the explicit filter matches no tests, helping prevent silent zero-test runs.
Where should unit and integration XML live in this lab?
Separate task-named directories under
build/test-results/test and
build/test-results/integrationTest.
Does shouldRunAfter(test) schedule the unit test
task?
No. It is ordering-only when both tasks are selected. The
check dependency is what schedules the integration
suite in the verification graph.
Why is the production resource useful in this lab?
It gives a local runtime-classpath proof: removing the project dependency can make the resource unavailable without needing a real database or service.
Official references and version notes
-
Gradle JVM Test Suite Plugin
— current suite/source-set/task model, additional-suite
dependencies, target tasks,
checkwiring, and outgoing test-result variants. - JvmTestSuite DSL — current API status and framework configuration. The API remains incubating in Gradle 9.7.1.
- Testing in Java & JVM Projects — test execution, filtering, reporting, JUnit Platform, manual integration-test fallback, and troubleshooting.
-
Gradle Test task
— forked test JVMs, reports, filtering,
maxParallelForks, failure behavior, and framework options. - TestFilter — class/method filters and fail-on-no-match behavior.
- Gradle 9.7.1 Release Notes — pinned Gradle baseline and current test-reporting behavior.
- JUnit 6.1.3 Overview — current JUnit Platform/Jupiter composition and Java 17+ runtime baseline.
-
JUnit 6.1.3 Tagging and Filtering
—
@Tagsemantics used in the examples.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.