Chapter 22Lesson 02~255 minutes

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.

Gradle 9.7.1JUnit 6.1.3integrationTest--testsReports

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 integrationTest JVM 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 check schedules 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())?

What proves check includes integration tests before executing them?

What should a typo in --tests do by default?

Where should unit and integration XML live in this lab?

Does shouldRunAfter(test) schedule the unit test task?

Why is the production resource useful in this lab?

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.