Gradle Testing JVM Projects
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 (the current release on 2026-10-16). Every example was run through the Gradle wrapper on macOS with JDK 26 (Oracle java version "26"), which is the JVM that runs the daemon and that compiles the code, apart from the one example that moves the tests to another JDK. The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and the tidepool build was run in both DSLs with the same commands; the console output is the same apart from timings and from the order in which independent tasks are reported. Every example ran with the configuration cache on (org.gradle.configuration-cache=true in gradle.properties) and reported no configuration cache problems, so all of them are compatible with it (a run with --warning-mode all printed no deprecation warning for the build of this page). The scratch project pointed its wrapper at a local copy of the distribution archive; the properties file that a real project commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
In the listings the lines about the configuration cache (Calculating task graph ..., Reusing configuration cache., Configuration cache entry stored. and the like) are left out, and the project directory of the scratch copy is replaced by /work/tidepool. A line consisting of ... marks output that was cut. The JVM default locale on the machine was Spanish; the Checkstyle examples set org.gradle.jvmargs=-Duser.language=en -Duser.country=US in gradle.properties, because otherwise Checkstyle prints its messages in Spanish, and no other output on this page depends on the locale. Features newer than Gradle 8.14 name their release. Gradle 8.14 was not installed here, so what differs in it comes from the official documentation and release notes and is labelled "not run here".
Testing is where the three build tools differ least in what they do and most in how they are told to do it. All of them compile the test sources against the production classes, start a JVM, hand the test classes to a framework and turn the result into a report that CI servers can read. Ant has no idea what a test is until you call the <junit> or <junitlauncher> task (see Testing with JUnit & junitlauncher); Maven binds the Surefire plugin to the test phase and the Failsafe plugin to integration-test (see Maven Testing with Surefire and Maven Integration Testing with Failsafe). Gradle has a task type, Test, that every JVM plugin instantiates for you, plus a declarative layer on top of it, the JVM Test Suite plugin, that is the recommended way to describe more than one kind of test. This page is about both layers, about the test frameworks Gradle can drive, about the reports it writes and about the plugins that measure and police tests: JaCoCo for coverage, Checkstyle and PMD for static checks. The Java plugins whose source sets the tests live in are on Gradle Java Projects; choosing the JDK that runs the tests is on Gradle Toolchains; testing a Gradle plugin with TestKit is on Testing & Publishing Plugins. For the language-level view of testing see Java Testing, Kotlin Testing and Groovy Testing with Spock & Geb.
The pieces: source set, Test task, test suite
Three objects take part in every test run, and most of the confusion about Gradle testing comes from not keeping them apart.
| Object | What it is | Where it comes from |
|---|---|---|
Source set |
A directory tree ( |
The |
|
A task that starts a separate JVM (the test worker), loads the test classes in it, runs them with a test framework and writes the results. Its inputs are the test classes, the runtime classpath and its own settings; its outputs are the XML results and the HTML report. |
Registered for each suite, so |
Test suite |
A named bundle of sources, dependencies, testing framework and one or more targets, each backed by a |
The JVM Test Suite plugin, applied by the |
Before 7.3 (2021) only the first two existed, and a second kind of tests meant a hand-made source set, configurations and a Test task, as shown on Gradle Java Projects. The JVM Test Suite plugin was introduced in Gradle 7.3 as an incubating feature and does the same in a few declarative lines. The java-test-fixtures plugin (Gradle 5.6) shares test helpers between projects, JUnit 5 support arrived in Gradle 4.6, and the sections below add what came later: richer HTML reports (9.3), non-class-based tests (9.4) and the changes of Gradle 9.0 that made the conventions of Test tasks explicit.
The example build
The running example is tidepool, the small tide prediction toolkit of the other pages of this section. For testing it has four subprojects: tidepool-model (records plus test fixtures), tidepool-engine (the TideEngine and a CSV StationReader, with three kinds of tests), tide-test-engine (a tiny JUnit Platform engine used in the section on non-class-based tests) and tidepool-reports (a project that only aggregates reports). The tree of the files that matter for testing, build output left out (the build.gradle.kts files are build.gradle in the Groovy DSL):
tidepool
|-- build.gradle.kts
|-- settings.gradle.kts
|-- gradle.properties
|-- gradle/libs.versions.toml
|-- data/stations.csv
|-- tidepool-model
| |-- build.gradle.kts
| `-- src
| |-- main/java/io/tidepool/model/...
| |-- test/java/io/tidepool/model/HeightTest.java
| `-- testFixtures/java/io/tidepool/model/Stations.java
|-- tidepool-engine
| |-- build.gradle.kts
| `-- src
| |-- main/java/io/tidepool/engine/{StationReader,TideEngine}.java
| |-- test/java/io/tidepool/engine/{StationReaderTest,TideEngineTest}.java
| |-- integrationTest/java/io/tidepool/engine/StationReaderIT.java
| `-- goldenTest/definitions/{diurnal/K1.tide,semidiurnal/M2.tide}
|-- tide-test-engine
| |-- build.gradle.kts
| `-- src/main/{java/io/tidepool/testengine/TideTestEngine.java,resources/META-INF/services/...}
`-- tidepool-reports
`-- build.gradle.kts
The settings file, the version catalog and gradle.properties are the same as on the other pages (repositories declared once, versions in the catalog), reduced to what this page needs:
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tide-test-engine", "tidepool-reports")
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tide-test-engine', 'tidepool-reports'
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
[versions]
commons-csv = "1.14.1"
junit = "6.1.3"
[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
junit-platform-engine = { module = "org.junit.platform:junit-platform-engine" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
The tests themselves are plain JUnit Jupiter. The engine’s unit tests, in tidepool-engine/src/test/java/io/tidepool/engine/TideEngineTest.java, use a parameterized test and the Stations fixture of the model project:
package io.tidepool.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.tidepool.model.Stations;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class TideEngineTest {
@Test
void flatWaterStaysFlat() {
assertEquals(0.0, TideEngine.predict(Stations.flatWater(), Instant.EPOCH).metres());
}
@Test
void pureM2StartsAtItsAmplitude() {
assertEquals(1.0, TideEngine.predict(Stations.pureM2(), Instant.EPOCH).metres(), 1e-9);
}
@ParameterizedTest(name = "t = {0} s")
@ValueSource(longs = {0, 3_600, 21_600, 43_200, 86_400})
void heightNeverExceedsTheAmplitude(long epochSecond) {
double metres = TideEngine.predict(Stations.pureM2(), Instant.ofEpochSecond(epochSecond)).metres();
assertTrue(Math.abs(metres) <= 1.0 + 1e-9, "height " + metres);
}
}
StationReaderTest parses a text block of CSV and has one test tagged @Tag("slow") that parses 20,000 stations; HeightTest in the model project checks a record and the fixture. The whole project was built with ./gradlew check (after clean in the Kotlin DSL copy) and ended with BUILD SUCCESSFUL in both DSLs.
The Test task
A Test task does five things, and each is a place where a build can be configured or can go wrong.
-
It resolves the test runtime classpath and the directories of compiled test classes (
testClassesDirs). Both are properties of the task; for thetesttask of thejavaplugin they are wired to thetestsource set. -
It starts a JVM, the test worker (the console calls it
Gradle Test Executor N), with the settings of the task:maxHeapSize,jvmArgs,systemProperty,environment,workingDir(the project directory by default), thejavaLauncher(the JDK) and so on. -
In that JVM, the tests are detected by inspecting the compiled test classes, with criteria that depend on the framework: for JUnit 3 and 4 a class that inherits from
TestCase, carries@RunWithor has an@Testmethod; for TestNG the methods annotated with@Test.scanForTestClasses = falseswitches the scan off so that only theincludeandexcludepatterns select classes. With the JUnit Platform the scan setting has no effect: onlyincludeandexcludefilter the classes, and the engines found on the classpath do the discovery. Abstract classes are not executed. -
It executes the tests and streams the events to the build, which prints them according to
testLogging, writes the XML result files and the HTML report, and fails the task when a test failed (unlessignoreFailuresis true). -
It records inputs and outputs, so that a second run with nothing changed is
UP-TO-DATE.
The properties used most often:
| Property or option | Effect |
|---|---|
|
Selects the framework of a hand-made |
|
JVM settings of the worker. Assertions ( |
|
Properties, environment variables and working directory of the worker. System properties of the Gradle process are not passed on, so only the ones set here reach the worker; environment variables are: the worker starts with the environment of the build process, and |
|
Select which classes or methods run (see "Selecting tests" below). |
|
How many worker JVMs run at the same time and after how many test classes a worker is replaced. |
|
Stop at the first failed test; let the build go on after failures. |
|
Since Gradle 9.0, fail when there are test sources but no test was discovered (default true). |
|
What is printed to the console while the tests run. |
|
Where and whether the HTML report and the XML result files are written. |
|
The JDK that runs the worker (a toolchain launcher). |
|
Directories with non-class-based test definition files (incubating, Gradle 9.4). |
|
Incubating: report the tests without executing them. |
The command line options of the task are listed by help --task:
./gradlew :tidepool-engine:help --task test
> Task :tidepool-engine:help
Detailed task information for test
Paths
:tide-test-engine:test (registered by plugin 'org.gradle.jvm-test-suite')
:tidepool-engine:test (registered by plugin 'org.gradle.jvm-test-suite')
:tidepool-model:test (registered by plugin 'org.gradle.jvm-test-suite')
Type
Test (org.gradle.api.tasks.testing.Test)
Options
--debug-jvm Enable debugging for the test process. The process is started suspended and listening on port 5005.
--no-debug-jvm Disables option --debug-jvm.
--fail-fast Stops test execution after the first failed test.
--no-fail-fast Disables option --fail-fast.
--test-dry-run Simulate test execution.
--no-test-dry-run Disables option --test-dry-run.
--tests Sets test class or method name to be included (in addition to the test task filters), '*' is supported.
--rerun Causes the task to be re-run even if up-to-date.
Description
Runs the test suite.
Group
verification
BUILD SUCCESSFUL in 847ms
--debug-jvm starts the worker suspended and waits for a debugger on port 5005, so it was not run here; the IDE integrations use the same mechanism. Running test the first time on the example prints the events of the default testLogging of the build (configured below) and the report task of JaCoCo:
./gradlew --configuration-cache :tidepool-engine:test
> Task :tidepool-engine:processTestResources NO-SOURCE
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:processTestFixturesResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
> Task :tidepool-model:compileTestFixturesJava
> Task :tidepool-model:testFixturesClasses
> Task :tidepool-model:testFixturesJar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:classes
> Task :tidepool-engine:compileTestJava
> Task :tidepool-engine:testClasses
> Task :tidepool-engine:test
StationReaderTest > parsesManyStations() PASSED
StationReaderTest > parsesOneStation() PASSED
TideEngineTest > flatWaterStaysFlat() PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 0 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 3600 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 21600 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 43200 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 86400 s PASSED
TideEngineTest > pureM2StartsAtItsAmplitude() PASSED
> Task :tidepool-engine:jacocoTestReport
BUILD SUCCESSFUL in 2s
8 actionable tasks: 8 executed
Two things in that listing are worth noticing. The test fixtures of the model project (compileTestFixturesJava, testFixturesJar) were built because the engine’s tests depend on them, and the engine’s test task ran after its inputs were ready without the build script ordering anything: task dependencies come from the dependencies of the classpath. A second run with nothing changed does not run the tests at all, which is the incremental build at work (see Incremental Build). The options --rerun (rerun this one task) and the task cleanTest (delete its outputs) force a run:
./gradlew --configuration-cache :tidepool-engine:test
./gradlew --configuration-cache :tidepool-engine:test --rerun
./gradlew --configuration-cache :tidepool-engine:cleanTest :tidepool-engine:test
> Task :tidepool-engine:test UP-TO-DATE
BUILD SUCCESSFUL in 730ms
> Task :tidepool-engine:test
BUILD SUCCESSFUL in 3s
> Task :tidepool-engine:cleanTest
> Task :tidepool-engine:test
BUILD SUCCESSFUL in 3s
(three separate runs; only the lines about the test task and the result are shown). --rerun applies to the one task it follows, unlike --rerun-tasks, which reruns every task of the build, compilation included. Do not make a Test task always run by switching up-to-date checking off (outputs.upToDateWhen { false }); use --rerun when you need it, because the build cache and the caching of the other tasks depend on the task being well behaved.
Source: Gradle User Manual: Test execution, Test detection, DSL reference: Test, Forcing tests to run, Debugging when running tests
The JUnit Platform and its launcher
Gradle runs JUnit 5 and 6 (JUnit Jupiter) on the JUnit Platform. Since Gradle 4.6 a Test task switches to the platform with useJUnitPlatform(). What the task needs on the test runtime classpath is more than the Jupiter API: it needs a Jupiter engine, and it needs the JUnit Platform launcher, which is the entry point that Gradle calls inside the worker. The launcher and the engines must have versions that belong together, which is why the JUnit team publishes a bill of materials (org.junit:junit-bom).
Older builds got the launcher for free: Gradle loaded some test framework classes from its own distribution. That behavior was deprecated in Gradle 8.x ("Relying on automatic test framework implementation dependencies", announced for removal in 9.0.0) because it caused version conflicts with the test classpath, and in Gradle 9 it is gone. A hand-written Test task therefore has to declare the launcher. The following engine build, which wires the framework by hand, shows what happens when it does not (the build was otherwise the same as the one above; this is a scratch variation):
plugins {
`java-library`
}
dependencies {
api(project(":tidepool-model"))
implementation(libs.commons.csv)
testImplementation(testFixtures(project(":tidepool-model")))
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
tasks.test {
useJUnitPlatform()
}
plugins {
id 'java-library'
}
dependencies {
api project(':tidepool-model')
implementation libs.commons.csv
testImplementation testFixtures(project(':tidepool-model'))
testImplementation platform('org.junit:junit-bom:6.1.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
tasks.test {
useJUnitPlatform()
}
> Task :tidepool-engine:test FAILED
Gradle Test Executor 27 FAILED
org.gradle.api.internal.tasks.testing.TestSuiteExecutionException at SuiteTestDefinitionProcessor.java:45
Caused by: org.gradle.api.internal.tasks.testing.RequiresTestFrameworkTestDefinitionProcessor$TestFrameworkNotAvailableException at JUnitPlatformTestDefinitionProcessor.java:101
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:test' (registered by plugin 'org.gradle.jvm-test-suite').
> Test process encountered an unexpected problem.
> Could not start Gradle Test Executor 27.
> Failed to load JUnit Platform. Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.
* Try:
> Check common problems https://docs.gradle.org/9.8.1/userguide/java_testing.html#sec:java_testing_troubleshooting.
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
The message names the fix: add testRuntimeOnly("org.junit.platform:junit-platform-launcher") (Groovy: testRuntimeOnly 'org.junit.platform:junit-platform-launcher') below the Jupiter dependency; the bill of materials supplies the version. With that line the same build ended with BUILD SUCCESSFUL in both DSLs. The error is also the reason the next section prefers test suites: a suite adds the framework, the launcher and the version together, so this class of mistake cannot occur.
The JVM Test Suite plugin
A test suite is declared under the testing extension. The java plugin has already created the suite test, which is the only one that sees the production code automatically (its source set output and the implementation dependencies of the project); every other suite has to ask for it. In tidepool-engine/build.gradle.kts the suite section is
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter(libs.versions.junit)
}
register<JvmTestSuite>("integrationTest") {
useJUnitJupiter(libs.versions.junit)
dependencies {
implementation(project())
}
targets.all {
testTask.configure {
shouldRunAfter(tasks.test)
}
}
}
// the goldenTest suite is shown in the section on non-class-based tests
}
}
tasks.check {
dependsOn(testing.suites.named("integrationTest"), testing.suites.named("goldenTest"))
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junit)
}
integrationTest(JvmTestSuite) {
useJUnitJupiter(libs.versions.junit)
dependencies {
implementation project()
}
targets.all {
testTask.configure {
shouldRunAfter tasks.test
}
}
}
// the goldenTest suite is shown in the section on non-class-based tests
}
}
tasks.check {
dependsOn testing.suites.integrationTest, testing.suites.goldenTest
}
The pieces, in the order of the listing:
-
useJUnitJupiter(version)selects the framework and addsjunit-jupiterand the launcher to the suite’s configurations, at the version given. Without an argument the plugin uses its built-in default, which is not necessarily the newest JUnit; in Gradle 9.8.1 the default is JUnit Jupiter 5.12.2 (adependenciesreport of a project that calleduseJUnitJupiter()without argument resolvedjunit-jupiter:5.12.2, platform 1.12.2). The Gradle 9.0 upgrade notes name the defaults of that release (JUnit Jupiter 5.12.2, TestNG 7.11.0, Spock 2.3). Pass the version, or a provider such aslibs.versions.junit, so that the framework is pinned in the version catalog. -
dependencies { implementation(project()) }adds the project’s own production classes to the suite.project()with no argument means "this project"; in thetestsuite this is implicit. Thedependenciesblock inside a suite is scoped to the suite, soimplementation,compileOnly,runtimeOnlyandannotationProcessorthere mean theintegrationTestImplementationand friends, and it is equivalent to using the long names in the top-level block. -
targets.all { testTask.configure { ... } }configures theTesttask of every target of the suite. Today a suite has exactly one target and its task has the suite’s name; the structure leaves room for several targets, for example one per JDK. -
shouldRunAfter(tasks.test)is only an ordering hint: the slower integration tests start after the unit tests when both are in the task graph, but nothing makes one a prerequisite of the other. It is a soft rule that Gradle may break to keep workers busy: in theclean checkrun of this pageintegrationTeststarted beforetest.mustRunAfteris the strict form. -
The suite does not attach itself to
check. A suite other thantestis not part of the verification lifecycle until the build says so, which is the last block above. Forgetting it is the classic bug: the task exists, the tests pass when it is run by name, and no CI build that runsbuildever executes it. The dry run ofcheckshows the wiring after the line was added (only the engine tasks are shown):
./gradlew --configuration-cache :tidepool-engine:check --dry-run
:tidepool-engine:goldenTestClasses SKIPPED
:tidepool-engine:goldenTest SKIPPED
:tidepool-engine:testClasses SKIPPED
:tidepool-engine:test SKIPPED
:tidepool-engine:integrationTestClasses SKIPPED
:tidepool-engine:integrationTest SKIPPED
:tidepool-engine:jacocoTestCoverageVerification SKIPPED
:tidepool-engine:jacocoTestReport SKIPPED
:tidepool-engine:check SKIPPED
Before the dependsOn line was added, the dry run of check listed test but neither integrationTest nor goldenTest. The verification group lists the tasks the suites created:
Verification tasks
------------------
check - Runs all checks.
goldenTest - Runs the golden test suite.
integrationTest - Runs the integration test suite.
jacocoTestCoverageVerification - Verifies code coverage metrics based on specified rules for the test task.
jacocoTestReport - Generates code coverage report for the test task.
test - Runs the test suite.
The integration test itself reads the shipped data file through the system property set in the common Test configuration (see "Logging, failures and fail-fast" below, which shows the systemProperty line). The integration test source lives in the default location for a suite, src/integrationTest/java, with no further configuration:
./gradlew --configuration-cache :tidepool-engine:integrationTest
> Task :tidepool-engine:integrationTest
StationReaderIT > readsTheThreeShippedStations() PASSED
BUILD SUCCESSFUL in 6s
(only the task and its result lines are shown). A suite also owns its own results and report: the integration test run above wrote build/test-results/integrationTest/ and build/reports/tests/integrationTest/, next to the test directories, so reports of different kinds never overwrite each other.
More of what the suite DSL offers, from the manual:
-
Sources:
sources { java { setSrcDirs(listOf("src/it/java")) } }(Groovy:srcDirs = ['src/it/java']) moves a suite’s source directory; the default issrc/<suite>/java. A suite’sTesttask can be configured intargets.all { testTask.configure { ... } }, as the manual does formaxHeapSize. -
Dependencies reaching other suites or projects:
implementation(project())for the own project,implementation(project(":other"))or the notationtestFixtures(project(...))for others. -
Sharing configuration between suites: a block applied to every suite (
withType<JvmTestSuite> { ... }in the Kotlin DSL,configureEach { ... }in Groovy), a filteredmatching { ... }.configureEach { ... }, or a configuration block extracted to a variable (the three methods of the manual). -
The frameworks:
useJUnitJupiter(),useJUnit()(JUnit 4),useTestNG()anduseSpock(); any suite other than the built-intestworks as ifuseJUnitJupiter()had been called, andtestmust always name its framework. -
Outgoing variants: each suite publishes its results as a variant that the aggregation plugins consume (see below).
The manual’s older text mentions a testType property of JvmTestSuite; it was removed in Gradle 8.13 (see "Gradle 8.14 and Gradle 9"), so builds should not set it.
Source: JVM Test Suite plugin: declare an additional test suite, Configure dependencies of a test suite, Reference project outputs, Configure source directories, Sharing configuration, Upgrading to Gradle 9: default versions of testing frameworks, Upgrading within 8.x: changes to JvmTestSuite
A hand-made Test task, and the Gradle 9 change
The suite is a convenience over the Test task; a builder who registers a task by hand still can. Until Gradle 8, a Test task registered with no configuration inherited the test classes directories and classpath of the test source set by convention, and it ran the same tests again, which hid the missing configuration. The convention was deprecated in 8.1 and removed in Gradle 9.0.0, and the notes warn that builds relying on it "will silently stop executing tests". This is what the scratch project did on 9.8.1:
// Created without any configuration: since Gradle 9.0 it has no test classes and no classpath.
tasks.register<Test>("otherTest")
// Created without any configuration: since Gradle 9.0 it has no test classes and no classpath.
tasks.register('otherTest', Test)
> Task :otherTest NO-SOURCE
BUILD SUCCESSFUL in 438ms
The task is green, nothing ran, and nothing says so unless the NO-SOURCE label is noticed. To run extra tests in a Test task, give it testClassesDirs and classpath (taken from the suite: testClassesDirs = files(test.map { it.sources.output.classesDirs }) and classpath = files(test.map { it.sources.runtimeClasspath }) in the notes' example), or better, add a target to the suite through the testing extension.
Test frameworks: Jupiter, JUnit 4, TestNG and Spock
The suite DSL names the framework; the engine that runs the tests is whatever the JUnit Platform finds on the runtime classpath. This decides which tests Gradle sees. A scratch project with its own small build.gradle.kts (and build.gradle) tested all four ways at once:
plugins {
`java-library`
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
testing {
suites {
// Jupiter tests plus old JUnit 4 tests in the same task, through the Vintage engine.
named<JvmTestSuite>("test") {
useJUnitJupiter("6.1.3")
dependencies {
implementation("junit:junit:4.13.2")
runtimeOnly("org.junit.vintage:junit-vintage-engine")
}
}
// A suite that is plain JUnit 4 and runs through the old runner.
register<JvmTestSuite>("junit4Test") {
useJUnit("4.13.2")
}
register<JvmTestSuite>("testNgTest") {
useTestNG("7.12.0")
}
}
}
tasks.withType<Test>().configureEach {
testLogging {
events("passed", "skipped", "failed")
}
}
tasks.check {
dependsOn(testing.suites.named("junit4Test"), testing.suites.named("testNgTest"))
}
plugins {
id 'java-library'
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
testing {
suites {
// Jupiter tests plus old JUnit 4 tests in the same task, through the Vintage engine.
test {
useJUnitJupiter('6.1.3')
dependencies {
implementation 'junit:junit:4.13.2'
runtimeOnly 'org.junit.vintage:junit-vintage-engine'
}
}
// A suite that is plain JUnit 4 and runs through the old runner.
junit4Test(JvmTestSuite) {
useJUnit('4.13.2')
}
testNgTest(JvmTestSuite) {
useTestNG('7.12.0')
}
}
}
tasks.withType(Test).configureEach {
testLogging {
events 'passed', 'skipped', 'failed'
}
}
tasks.check {
dependsOn testing.suites.junit4Test, testing.suites.testNgTest
}
The sources are one JUnit 4 class (org.junit.Test, org.junit.Assert) and one Jupiter class under src/test/java, one JUnit 4 class under src/junit4Test/java and one TestNG class with a @DataProvider under src/testNgTest/java. check ran all three tasks:
> Task :junit4Test
PlainJUnit4Test > runsWithTheJUnit4Runner PASSED
> Task :testNgTest
Gradle suite > Gradle test > legacy.TestNgTest > adds[0](1, 1, 2) PASSED
Gradle suite > Gradle test > legacy.TestNgTest > adds[1](2, 2, 4) PASSED
> Task :test
NewStyleTest > jupiterTest() PASSED
OldStyleTest > junit4TestRunsOnTheJUnitPlatform PASSED
> Task :check
BUILD SUCCESSFUL in 14s
What each line of the build does:
-
JUnit 4 with the Vintage engine. The
junit-vintage-engineruns JUnit 3 and 4 tests inside the JUnit Platform, so oneTesttask runs a codebase that is migrating (the book’s era and most existing code bases are JUnit 4). It needs the JUnit 4 library on the compile classpath of the tests (implementation("junit:junit:4.13.2")) and the Vintage engine at runtime. The bill of materials supplies the Vintage version. The sibling test class,NewStyleTest, is Jupiter. Both appear in thetesttask output above. -
Without the Vintage engine, JUnit 4 tests silently disappear. With the
runtimeOnlyline removed, the sametesttask printedNewStyleTest > jupiterTest() PASSEDonly and still ended withBUILD SUCCESSFUL: the platform has no engine for@org.junit.Test, and one class with tests discovered is enough for the check described next to pass. -
useJUnit("4.13.2")is the original JUnit 4 runner (no JUnit Platform). It addsjunit:junititself, runs@Testclasses of JUnit 4 and supports categories (useJUnit { includeCategories("...") }). -
useTestNG("7.12.0")runs TestNG and adds it to the suite. In the console, TestNG tests are named after the suite and test of TestNG (Gradle suite > Gradle test > legacy.TestNgTest), and data-provider invocations are listed with their index and parameters (adds[0](1, 1, 2)). TestNG-specific options (useTestNG { suites("..."); includeGroups("fast") }) are in the manual; Gradle 9.7.0 supports the new way of controlling thread pools in TestNG 7.10 and later. -
Spock:
useSpock()adds the Spock engine, whose default version in Gradle 9 is 2.3, and runs specifications on the JUnit Platform; the Groovy side is covered on Groovy Testing with Spock & Geb and needs thegroovyplugin so that the specification classes compile. It was not run here.
The silent drop above has a counterpart that is not silent. When there are test sources and the engine finds no test at all, Gradle 9 fails the task. A scratch project with a single JUnit 4 test class and a Jupiter-only test suite printed:
...
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There are test sources present and no filters are applied, but the test task did not discover any tests to execute. This is likely due to a misconfiguration. Please check your test configuration. If this is not a misconfiguration, this error can be disabled by setting the 'failOnNoDiscoveredTests' property to false.
* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 627ms
2 actionable tasks: 2 executed
Configuration cache entry stored.
That is the Gradle 9.0 behavior ("test task fails when no tests are discovered"); in Gradle 8 a green build was printed (not run here), and Gradle 8.x announced the change as a deprecation ("Deprecated running test task successfully when no test executed"). The switch is failOnNoDiscoveredTests = false on the task, for a module whose test sources are intentionally empty. The failure appears only when every class is invisible, so mixed projects still need the Vintage line.
Source: Executing legacy tests with JUnit Vintage, Troubleshooting: tests are not detected or executed, Upgrading to Gradle 9: test task fails when no tests are discovered, Upgrading within 8.x: running test task successfully when no test executed, Gradle 9.7.0 release notes: TestNG thread pool factories
Selecting tests
A build with thousands of tests needs ways to run a subset, from the command line while developing and from the build script for the CI stages. Gradle has four mechanisms; use the first one most often.
--tests on the command line./gradlew --configuration-cache :tidepool-engine:test --tests 'io.tidepool.engine.TideEngineTest.pureM2*'
./gradlew --configuration-cache :tidepool-engine:test --tests '*StationReader*'
./gradlew --configuration-cache :tidepool-engine:test --tests '*flatWater*' --tests '*parsesOne*'
> Task :tidepool-engine:test
TideEngineTest > pureM2StartsAtItsAmplitude() PASSED
> Task :tidepool-engine:test
StationReaderTest > parsesManyStations() PASSED
StationReaderTest > parsesOneStation() PASSED
> Task :tidepool-engine:test
StationReaderTest > parsesOneStation() PASSED
TideEngineTest > flatWaterStaysFlat() PASSED
(three separate runs; only the lines of the test task are shown). The rules are those of the manual: a pattern is a fully qualified class name, optionally followed by a dot and a method name; since Gradle 4.7 a pattern that starts with an upper-case letter is taken as a simple class name (--tests TideEngineTest), and the wildcard * is purely textual and matches dots too, so *.TideEngineTest matches any package. The option can be repeated and all patterns take effect, filters declared in the build script still apply on top of --tests, and a parameterized method is selected by its method name (the manual shows *ParameterizedTest.*[2] for one invocation). Combined with --continuous it reruns a subset after every change. A pattern that selects nothing is an error and not an empty success:
> Task :tidepool-engine:test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:test'.
> No tests found for given includes: [NoSuchTest](--tests filter)
* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 550ms
The build script has the same filter as an API, filter { includeTestsMatching("...") }, plus excludeTestsMatching, includeTest(className, methodName) and isFailOnNoMatchingTests. The older include "**/IT*.class" and exclude patterns work on class file paths, are still useful with scanForTestClasses = false and are what separates the Ant and Maven (**/*Test.java) worlds from this one. The JUnit Platform has its own, framework-level selection through tags:
tasks.test {
useJUnitPlatform {
if (providers.gradleProperty("fast").isPresent) {
excludeTags("slow")
}
}
}
tasks.test {
useJUnitPlatform {
if (providers.gradleProperty('fast').present) {
excludeTags 'slow'
}
}
}
./gradlew --configuration-cache :tidepool-engine:test -Pfast
> Task :tidepool-engine:test
StationReaderTest > parsesOneStation() PASSED
TideEngineTest > flatWaterStaysFlat() PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 0 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 3600 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 21600 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 43200 s PASSED
TideEngineTest > heightNeverExceedsTheAmplitude(long) > t = 86400 s PASSED
TideEngineTest > pureM2StartsAtItsAmplitude() PASSED
The -Pfast run executed eight tests; the unfiltered run executes nine, the extra one being parsesManyStations tagged slow. The property is read through the provider API (providers.gradleProperty(...)), which is what keeps the example compatible with the configuration cache: a plain project.hasProperty("fast") would also work, but the provider records the property as an input of the cached configuration. includeTags and excludeTags accept tag expressions of the JUnit Platform, and a task can also pick tests by engine (includeEngines("junit-jupiter")). Task-level selection does not replace separate suites: if the "fast" and "slow" tests need different dependencies, a different working directory or a different JVM, give them different suites.
The fourth mechanism is --test-dry-run (the property dryRun, incubating): it discovers the tests and reports each as skipped without executing it.
./gradlew --configuration-cache :tidepool-engine:test --test-dry-run
> Task :tidepool-engine:test
StationReaderTest > parsesManyStations() SKIPPED
StationReaderTest > parsesOneStation() SKIPPED
TideEngineTest > flatWaterStaysFlat() SKIPPED
TideEngineTest > pureM2StartsAtItsAmplitude() SKIPPED
> Task :tidepool-engine:jacocoTestReport
BUILD SUCCESSFUL in 2s
The parameterized test heightNeverExceedsTheAmplitude is not listed; the dry run reports four of the nine tests. Note also that the JaCoCo report task still ran, because it is finalizedBy of test in this build, and found no coverage data of its own.
Logging, failures and fail-fast
At the default log level (lifecycle) Gradle prints only failed tests, with a short exception format, plus a summary line and the report location; skipped tests and the output of tests appear at --info. The testLogging block of the common Test configuration in tidepool-engine turns events on; it is also the place for the system property used by the integration test and for the heap size of the worker:
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
tasks.withType<Test>().configureEach {
maxHeapSize = "512m"
systemProperty("tidepool.data", layout.projectDirectory.dir("../data").asFile.path)
testLogging {
events("passed", "skipped", "failed")
exceptionFormat = TestExceptionFormat.FULL
}
}
tasks.withType(Test).configureEach {
maxHeapSize = '512m'
systemProperty 'tidepool.data', layout.projectDirectory.dir('../data').asFile.path
testLogging {
events 'passed', 'skipped', 'failed'
exceptionFormat = 'full'
}
}
The logging options are events (started, passed, skipped, failed, standardOut, standardError), exceptionFormat (short, full), showExceptions, showCauses, showStackTraces, showStandardStreams (a shortcut for the two stream events), stackTraceFilters and minGranularity / maxGranularity (which levels of the test hierarchy are shown). Since Gradle 9.7.0 framework start-up failures of TestNG, JUnit 4 and the JUnit Platform (for example an exception thrown from a TestNG test class constructor) are always printed, even when the granularity settings would hide them. To see failure output in a controlled test, a scratch class RoundingTest was added to the engine, with a deliberately wrong assertion, a test that prints to standard output and a @Disabled test:
class RoundingTest {
@Test
void halfwayThroughAQuarterCycle() {
// M2 has a period of 12.42 h, so a quarter cycle is not 3 h: this assertion is wrong on purpose.
double metres = TideEngine.predict(Stations.pureM2(), Instant.ofEpochSecond(3 * 3600)).metres();
assertEquals(0.0, metres, 1e-6, "height after three hours");
}
@Test
void writesToStandardOutput() {
System.out.println("predicting for the pure M2 station");
assertEquals(1.0, TideEngine.predict(Stations.pureM2(), Instant.EPOCH).metres(), 1e-9);
}
@Disabled("waiting for the datum conversion")
@Test
void convertsToChartDatum() {
}
}
./gradlew --configuration-cache :tidepool-engine:test
> Task :tidepool-engine:test FAILED
RoundingTest > convertsToChartDatum() SKIPPED
RoundingTest > halfwayThroughAQuarterCycle() FAILED
org.opentest4j.AssertionFailedError: height after three hours ==> expected: <0.0> but was: <0.05316710997744344>
at app//org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1073)
at app//io.tidepool.engine.RoundingTest.halfwayThroughAQuarterCycle(RoundingTest.java:15)
RoundingTest > writesToStandardOutput() PASSED
StationReaderTest > parsesManyStations() PASSED
...
TideEngineTest > pureM2StartsAtItsAmplitude() PASSED
12 tests completed, 1 failed, 1 skipped
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:test'.
> There were failing tests. See the report at: file:///work/tidepool/tidepool-engine/build/reports/tests/test/index.html
* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 2s
The failure message is the one of the assertion library, the stack trace is filtered to the first frames of the test, and the task failure points to the HTML report. The other switches, each run in the same scratch project:
-
--fail-faststops after the first failed test. The remaining tests are reported as skipped: the run printed4 tests completed, 1 failed, 2 skipped(instead of the 12 of the full run), andStationReaderTest > parsesManyStations() SKIPPED. The build script property isfailFast = true. -
showStandardStreams = trueprints what the test wrote while it ran, attached to the test that wrote it:
RoundingTest > writesToStandardOutput() STANDARD_OUT
predicting for the pure M2 station
RoundingTest > writesToStandardOutput() PASSED
-
ignoreFailures = truelets the build continue after failures; the output shows the failure andBUILD SUCCESSFUL. This is meant for builds that still want their reports aggregated and published; the usual alternative is to keep the default and add--continueon the command line, so that other tasks still run but the build fails. A subtle result of the combination with the up-to-date check: the run withignoreFailuresprinted the failure the first time and a second invocation printed> Task :tidepool-engine:test UP-TO-DATEandBUILD SUCCESSFUL, so a red test stays hidden until--rerun.
The XML result files carry the full standard output and error of every test class as well (<system-out>), and since Gradle 9.3 the output stays attached to the test that produced it instead of being merged into the class.
Reports and results
Every Test task writes two representations of the same run, plus a binary one that Gradle uses to build them. For the test task of the engine:
tidepool-engine/build/test-results/test/TEST-io.tidepool.engine.StationReaderTest.xml
tidepool-engine/build/test-results/test/TEST-io.tidepool.engine.TideEngineTest.xml
tidepool-engine/build/test-results/test/binary/output-events.bin
tidepool-engine/build/test-results/test/binary/results-generic.bin
tidepool-engine/build/reports/tests/test/index.html
tidepool-engine/build/reports/tests/test/io.tidepool.engine.StationReaderTest/index.html
tidepool-engine/build/reports/tests/test/io.tidepool.engine.TideEngineTest/index.html
tidepool-engine/build/reports/tests/test/io.tidepool.engine.TideEngineTest/heightNeverExceedsTheAmplitude(long)/index.html
...
(the CSS and JavaScript files of the report are left out). The directories are named after the task, so integrationTest and goldenTest write next to test, never over it. The XML is the JUnit XML format that CI servers (Jenkins, GitHub Actions test summaries, GitLab, Azure DevOps) read; one file per test class, with one testcase element per test:
<testsuite name="io.tidepool.engine.TideEngineTest" tests="7" skipped="0" failures="0" errors="0" ...>
<properties/>
<testcase name="flatWaterStaysFlat()" classname="io.tidepool.engine.TideEngineTest" time="0.002"/>
<testcase name="t = 0 s" classname="io.tidepool.engine.TideEngineTest" time="0.006"/>
...
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
(the timestamp and hostname attributes are cut). The reports block of the task switches each report on or off and moves it: reports { html.required = false; junitXml.outputLocation = layout.buildDirectory.dir("ci-results") }, and the outputPerTestCase and mergeReruns properties of the junitXml report control how output and reruns of flaky tests are recorded (not run here). The binary results are not meant to be read by hand; TestReport tasks and the Tooling API use them.
What changed in the HTML report over the Gradle 9 line:
-
9.3.0: a more detailed HTML report. It now shows nested JUnit classes nested under their enclosing class, a parameterized test as a suite of its invocations (
paramTest1containingparamTest1[0],paramTest1[1]), suites of JUnit 4, Jupiter and TestNG as containers of their classes, and no synthetic package level (a class is listed with its fully qualified name). The standard output and error stay with the test that wrote them. The aggregate report distinguishes its sources as separate tabs. The XML keeps its format. The hierarchy in the report corresponds to the one printed bytestLoggingwithminGranularityandmaxGranularity, which is the reason a parameterized test appears asheightNeverExceedsTheAmplitude(long) > t = 0 sin the console listing above. -
9.4.0: JUnit Platform tests can publish additional data with the
TestReporterAPI (publishEntry("key", "value"),publishFile(...)), which Gradle captures into the HTML report (two extra tabs) and the XML, and aTestMetadataListenercan observe it throughaddTestMetadataListener. Screenshots of failed UI tests are the typical use. This was not run here. -
9.6.0: sortable columns in the HTML test report (numeric columns sort descending on the first click, the success rate ascending), which helps to find the failing classes of a large project.
The aggregate and coverage reports are described in the sections below. Build Scans (--scan) show the same results online with timelines; publishing a scan means accepting terms of use and was not done here.
Forks, parallelism and the JDK of the tests
A test worker is a JVM, and there are three levers over how many of them run and what they run on.
tasks.withType<Test>().configureEach {
maxParallelForks = 2 // at most two worker JVMs at the same time
forkEvery = 1 // replace the worker after every test class
}
tasks.withType(Test).configureEach {
maxParallelForks = 2 // at most two worker JVMs at the same time
forkEvery = 1 // replace the worker after every test class
}
maxParallelForks (default 1) divides the test classes between that many workers, so several classes run at the same time and each one in its own JVM; a common value is half the number of cores, Runtime.getRuntime().availableProcessors().div(2).coerceAtLeast(1) (not run here). forkEvery (default 0, no limit) restarts the worker after that many classes, which isolates tests that leak state at a high startup cost. The effect was observed with four scratch classes that sleep for 400 ms and print the worker that ran them (org.gradle.test.worker), run with --tests selecting only those four:
(default: one worker)
AlphaTest ran in worker 39
BravoTest ran in worker 39
CharlieTest ran in worker 39
DeltaTest ran in worker 39
(maxParallelForks = 2)
AlphaTest ran in worker 2
BravoTest ran in worker 1
CharlieTest ran in worker 2
DeltaTest ran in worker 1
(maxParallelForks = 2 and forkEvery = 1)
BravoTest ran in worker 40
AlphaTest ran in worker 41
DeltaTest ran in worker 42
CharlieTest ran in worker 43
The number is the value of the org.gradle.test.worker system property, which the manual describes as unique for each test process (and useful for naming files and other resources so that parallel tests do not collide). What matters here is how many different values appear: one, two, and four (a new JVM for every class). The Groovy DSL run printed four distinct workers for the last setting as well. Forks are process level parallelism, between test classes. Inside a worker, JUnit Jupiter can also run classes and methods on several threads: set junit.jupiter.execution.parallel.enabled=true (and junit.jupiter.execution.parallel.mode.default=concurrent) in junit-platform.properties on the test classpath, or as systemProperty of the task; tests that share mutable state or files must then be written for it. Parallel forks need tests that do not share ports, files or databases.
The JDK that runs the workers is independent of the JDK of the daemon and of the one that compiles the code. A test task takes a javaLauncher from the toolchain service (see Gradle Toolchains):
// Run the tests on another JDK than the one that runs Gradle.
tasks.withType<Test>().configureEach {
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
}
}
// Run the tests on another JDK than the one that runs Gradle.
tasks.withType(Test).configureEach {
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
}
}
A scratch test printed Runtime.version().feature() and java.vendor with showStandardStreams on. With org.gradle.java.installations.auto-download=false in gradle.properties (so no JDK could be downloaded) the daemon ran on JDK 26 and the tests ran on the installed JetBrains Runtime 21:
JvmTest > reportsTheJvmThatRunsTheTests() STANDARD_OUT
tests run on Java 21 (JetBrains s.r.o.)
JvmTest > reportsTheJvmThatRunsTheTests() PASSED
JaCoCo’s agent worked on that JDK too (the report task ran after the tests and the build ended BUILD SUCCESSFUL). The same mechanism tests a library on the oldest and the newest JDK it supports: one suite target per JDK is the use case the suite structure was designed for. A toolchain whose JDK is missing makes the task fail, or downloads the JDK when a resolver is configured (which was disabled here).
Test fixtures
Test helpers (builders, sample data, fake servers, custom assertions) are needed by the tests of several projects. Two classic solutions are bad: copying them, and putting them in main, where they ship to users. The Maven answer is a test-jar of the test classes, which does not carry the test-scoped dependencies along (a consumer has to declare them again); Gradle’s answer is the java-test-fixtures plugin, available since Gradle 5.6, which adds a source set testFixtures with its own dependencies, a JAR with the classifier test-fixtures and a feature that other projects can depend on.
plugins {
`java-library`
`java-test-fixtures`
jacoco
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter(libs.versions.junit)
}
}
}
plugins {
id 'java-library'
id 'java-test-fixtures'
id 'jacoco'
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junit)
}
}
}
(the JaCoCo task wiring that both scripts also contain is shown in the JaCoCo section). The fixtures are ordinary classes under src/testFixtures/java:
package io.tidepool.model;
import java.util.List;
/** Ready-made stations for tests in this and in other projects. */
public final class Stations {
private Stations() {
}
/** A station with a single M2 constituent of amplitude 1 m and phase 0. */
public static Station pureM2() {
return new Station("M2", "Pure M2", 0, 0, List.of(new Constituent("M2", 1.0, 0)));
}
/** A station without constituents: the sea never moves. */
public static Station flatWater() {
return new Station("FLAT", "Flat water", 0, 0, List.of());
}
}
The plugin gives the project the configurations testFixturesApi, testFixturesImplementation, testFixturesCompileOnly and testFixturesRuntimeOnly; the fixtures automatically see the production classes (an api-like dependency of testFixtures on main), and the project’s own test suite sees the fixtures (the HeightTest of the model uses Stations). Another project consumes the fixtures with the testFixtures(...) notation, as the engine does (testImplementation(testFixtures(project(":tidepool-model"))) in the engine’s dependencies; Groovy: testImplementation testFixtures(project(':tidepool-model'))). The build produced the extra JAR next to the normal one:
./gradlew -q :tidepool-model:build
ls tidepool-model/build/libs
tidepool-model-1.0.0-test-fixtures.jar
tidepool-model-1.0.0.jar
The fixtures are a feature of the same module, with their own capability, which is how dependency resolution and publishing know them (see Variant-aware Resolution & Module Metadata). outgoingVariants prints it:
./gradlew -q :tidepool-model:outgoingVariants --variant testFixturesApiElements
--------------------------------------------------
Variant testFixturesApiElements
--------------------------------------------------
API elements for the 'testFixtures' feature.
Capabilities
- io.tidepool:tidepool-model-test-fixtures:1.0.0
Attributes
- org.gradle.category = library
- org.gradle.dependency.bundling = external
- org.gradle.jvm.version = 21
- org.gradle.libraryelements = jar
- org.gradle.usage = java-api
Artifacts
- build/libs/tidepool-model-1.0.0-test-fixtures.jar (artifactType = jar, classifier = test-fixtures)
Secondary Variants (*)
...
When the library is published, the fixtures go along: by convention an artifact with the classifier test-fixtures is published next to the regular ones, and with maven-publish or ivy-publish they are also published as additional variants in Gradle Module Metadata, so that another Gradle project can write testFixtures("group:module:version"). A consumer of a module that publishes no Gradle Module Metadata gets a resolution error that no variant with the capability test-fixtures exists. A project that should not publish its fixtures can skip the two variants (components.java.withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } and the same for testFixturesRuntimeElements, not run here); publishing is on Publishing. The fixtures' own dependencies are declared with testFixturesApi when the fixture API exposes them (for example a JUnit or AssertJ type in a signature), so that consumers get them at compile time too.
Non-class-based tests (Gradle 9.4)
Some test frameworks do not describe tests as classes: Cucumber with its .feature files, tools that read XML, YAML or CSV scripts, golden-file tests. They run on the JUnit Platform as test engines that discover tests in files. Before Gradle 9.4 such a build needed a workaround (an empty test class to satisfy Gradle’s class-based discovery, or a JUnit extension). Gradle 9.4.0 added direct support: the Test task takes a property testDefinitionDirs with the directories to scan, gives the engine a DirectorySelector for each of them and reports the tests it finds. The feature is incubating, as are the filtering rules for non-class-based tests.
The example is a tiny engine, tide-test-engine, that reads *.tide files. Each non-comment line name amplitude phase epochSecond expected is one test: it builds a one-constituent station, asks the TideEngine for the height at the instant and compares it with the expected value within 1 mm. The golden files live in tidepool-engine/src/goldenTest/definitions:
# semidiurnal/M2.tide
M2 1.0 0 0 1.0
M2 1.0 90 0 0.0
M2 0.5 0 21600 -0.4972
# diurnal/K1.tide
K1 0.38 210 0 -0.3291
K1 1.0 0 86400 0.9999
The engine is a normal JUnit Platform TestEngine, registered with ServiceLoader through META-INF/services/org.junit.platform.engine.TestEngine (a file whose only line is io.tidepool.testengine.TideTestEngine). It depends on the platform’s engine API, taken from the bill of materials:
plugins {
`java-library`
}
dependencies {
api(platform(libs.junit.bom))
api(libs.junit.platform.engine)
implementation(project(":tidepool-engine"))
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
}
plugins {
id 'java-library'
}
dependencies {
api platform(libs.junit.bom)
api libs.junit.platform.engine
implementation project(':tidepool-engine')
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
package io.tidepool.testengine;
import io.tidepool.engine.TideEngine;
import io.tidepool.model.Constituent;
import io.tidepool.model.Station;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.stream.Stream;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.ExecutionRequest;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.TestEngine;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.discovery.DirectorySelector;
import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
import org.junit.platform.engine.support.descriptor.EngineDescriptor;
import org.junit.platform.engine.support.descriptor.FileSource;
/**
* A tiny JUnit Platform engine for non-class-based tests: every line of a *.tide file
* "name amplitude phase epochSecond expected" is one test that compares the prediction of the
* tide engine for a one-constituent station with the expected height in metres.
*/
public final class TideTestEngine implements TestEngine {
@Override
public String getId() {
return "tide-engine";
}
@Override
public TestDescriptor discover(EngineDiscoveryRequest request, UniqueId uniqueId) {
EngineDescriptor root = new EngineDescriptor(uniqueId, "Tide golden files");
for (DirectorySelector selector : request.getSelectorsByType(DirectorySelector.class)) {
Path dir = selector.getPath();
try (Stream<Path> files = Files.walk(dir)) {
files.filter(f -> f.toString().endsWith(".tide")).sorted().forEach(f -> addFile(root, dir, f));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
return root;
}
private static void addFile(EngineDescriptor root, Path dir, Path file) {
String name = dir.relativize(file).toString().replace('/', '.').replaceAll("\\.tide$", "");
FileDescriptor container = new FileDescriptor(root.getUniqueId().append("file", name), name, file);
try {
List<String> lines = Files.readAllLines(file);
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i).strip();
if (!line.isEmpty() && !line.startsWith("#")) {
container.addChild(new CaseDescriptor(container.getUniqueId().append("line", String.valueOf(i + 1)),
file, i + 1, line));
}
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
root.addChild(container);
}
@Override
public void execute(ExecutionRequest request) {
var listener = request.getEngineExecutionListener();
TestDescriptor root = request.getRootTestDescriptor();
listener.executionStarted(root);
for (TestDescriptor container : root.getChildren()) {
listener.executionStarted(container);
for (TestDescriptor test : container.getChildren()) {
listener.executionStarted(test);
try {
((CaseDescriptor) test).run();
listener.executionFinished(test, TestExecutionResult.successful());
} catch (Throwable t) {
listener.executionFinished(test, TestExecutionResult.failed(t));
}
}
listener.executionFinished(container, TestExecutionResult.successful());
}
listener.executionFinished(root, TestExecutionResult.successful());
}
private static final class FileDescriptor extends AbstractTestDescriptor {
FileDescriptor(UniqueId id, String name, Path file) {
super(id, name, FileSource.from(file.toFile()));
}
@Override
public Type getType() {
return Type.CONTAINER;
}
}
private static final class CaseDescriptor extends AbstractTestDescriptor {
private final String line;
CaseDescriptor(UniqueId id, Path file, int number, String line) {
super(id, line, FileSource.from(file.toFile(), org.junit.platform.engine.support.descriptor.FilePosition.from(number)));
this.line = line;
}
@Override
public Type getType() {
return Type.TEST;
}
void run() {
String[] p = line.split("\\s+");
Station station = new Station(p[0], p[0], 0, 0, List.of(new Constituent(p[0], Double.parseDouble(p[1]), Double.parseDouble(p[2]))));
double actual = TideEngine.predict(station, Instant.ofEpochSecond(Long.parseLong(p[3]))).metres();
double expected = Double.parseDouble(p[4]);
if (Math.abs(actual - expected) > 0.001) {
throw new AssertionError("expected " + expected + " m but the engine predicted " + actual + " m");
}
}
}
}
The golden test suite needs three things, which are the three steps of the manual: the Jupiter platform (so that the launcher is present), the engine on the runtime classpath, and the definition directory. The suite has no Java sources at all, so the task for compiling its sources reports NO-SOURCE:
register<JvmTestSuite>("goldenTest") {
useJUnitJupiter(libs.versions.junit)
dependencies {
runtimeOnly(project(":tide-test-engine"))
}
targets.all {
testTask.configure {
testDefinitionDirs.from("src/goldenTest/definitions")
}
}
}
goldenTest(JvmTestSuite) {
useJUnitJupiter(libs.versions.junit)
dependencies {
runtimeOnly project(':tide-test-engine')
}
targets.all {
testTask.configure {
testDefinitionDirs.from('src/goldenTest/definitions')
}
}
}
./gradlew --configuration-cache :tidepool-engine:goldenTest
> Task :tidepool-engine:goldenTest
diurnal.K1 > K1 0.38 210 0 -0.3291 PASSED
diurnal.K1 > K1 1.0 0 86400 0.9999 PASSED
semidiurnal.M2 > M2 1.0 0 0 1.0 PASSED
semidiurnal.M2 > M2 1.0 90 0 0.0 PASSED
semidiurnal.M2 > M2 0.5 0 21600 -0.4972 PASSED
BUILD SUCCESSFUL in 21s
(the first run, which also built the engine project, took 21 s). The rules the engine and the task agree on show in the output. The name of a non-class-based test container is built from the relative path of the file: semidiurnal/M2.tide becomes semidiurnal.M2, and that is the name filters match, so the --tests option works at file granularity:
./gradlew --configuration-cache :tidepool-engine:goldenTest --tests 'semidiurnal.*'
semidiurnal.M2 > M2 1.0 0 0 1.0 PASSED
semidiurnal.M2 > M2 1.0 90 0 0.0 PASSED
semidiurnal.M2 > M2 0.5 0 21600 -0.4972 PASSED
The manual warns that the conversion from a path to a name is ambiguous when directories or files contain dots or spaces (sub.ExampleTests.xml, sub/ExampleTests.xml and sub/ExampleTests all become sub.ExampleTests), so non-class-based definitions should follow Java package naming for directories and use simple file names. The result files are named after the container as well, in the XML (TEST-diurnal-K1.tide.xml, with classname="diurnal/K1.tide" for its test cases) and in the HTML report. A failing definition behaves like any failing test, with the engine’s own message as the failure. Changing the expected value of the last M2 line to 0.4972 (the sign is wrong) printed:
> Task :tidepool-engine:goldenTest FAILED
diurnal.K1 > K1 0.38 210 0 -0.3291 PASSED
diurnal.K1 > K1 1.0 0 86400 0.9999 PASSED
semidiurnal.M2 > M2 1.0 0 0 1.0 PASSED
semidiurnal.M2 > M2 1.0 90 0 0.0 PASSED
semidiurnal.M2 > M2 0.5 0 21600 0.4972 FAILED
java.lang.AssertionError: expected 0.4972 m but the engine predicted -0.49717325841664645 m
at io.tidepool.testengine.TideTestEngine$CaseDescriptor.run(TideTestEngine.java:118)
at io.tidepool.testengine.TideTestEngine.execute(TideTestEngine.java:77)
5 tests completed, 1 failed
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:goldenTest'.
> There were failing tests. See the report at: file:///work/tidepool/tidepool-engine/build/reports/tests/goldenTest/index.html
Use real engines for real work: a Cucumber project registers cucumber-junit-platform-engine as runtimeOnly and points testDefinitionDirs at src/test/resources or wherever the .feature files are, as the 9.4.0 notes show (not run here, since the build of this page has no Cucumber). The manual recommends not mixing class-based and non-class-based tests in one task: separate suites let each have its own engines, dependencies and report. The three-step structure is also how a custom engine for any text-driven test format (SQL scripts, HTTP request collections) is hooked into the build.
Code coverage with JaCoCo
The jacoco plugin attaches the JaCoCo agent to every Test task of the project, records coverage in a .exec file per task, and adds two tasks for the test task: jacocoTestReport (reports) and jacocoTestCoverageVerification (rules that can fail the build). It replaces the EMMA and Cobertura of the era of the book: EMMA is dead, the Cobertura plugin was always third party and unmaintained, and JaCoCo is the core Gradle plugin.
plugins {
`java-library`
jacoco
}
tasks.test {
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
csv.required = true
}
}
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = "0.80".toBigDecimal()
}
}
}
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
plugins {
id 'java-library'
id 'jacoco'
}
tasks.test {
finalizedBy tasks.jacocoTestReport
}
tasks.jacocoTestReport {
dependsOn tasks.test
reports {
xml.required = true
csv.required = true
}
}
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.80
}
}
}
}
tasks.check {
dependsOn tasks.jacocoTestCoverageVerification
}
The pieces:
-
finalizedBy(tasks.jacocoTestReport)makes the report run after everytest, even when tests fail (a failed run is when the report is most useful).dependsOn(tasks.test)on the report does the reverse, so that asking for the report runs the tests first. The plugin does neither for you, and a report with no execution data is skipped. -
Report formats: HTML (default, in
build/reports/jacoco/test/html), XML (xml.required, the input of SonarQube, Codecov and most CI integrations) and CSV. Each has anoutputLocation. -
Coverage counters are
INSTRUCTION,BRANCH,LINE,COMPLEXITY,METHODandCLASS; their values areCOVEREDRATIO,MISSEDRATIO,COVEREDCOUNT,MISSEDCOUNTandTOTALCOUNT. A rule applies to anelement(BUNDLE, the default, orPACKAGE,CLASS,SOURCEFILE,METHOD) and haslimitblocks;includesandexcludesnarrow it to classes. In the Kotlin DSL a limit’sminimumis aBigDecimal, which is why the value is written"0.80".toBigDecimal(). -
jacoco { toolVersion = "..." }selects the JaCoCo version. The default of Gradle 9.8.1, taken from the resolvedjacocoAgentconfiguration of the build, is 0.8.15 (8.14 used 0.8.13). The JaCoCo version limits the class file versions that can be instrumented, so a build on a new JDK needs a new enough JaCoCo, which is one more reason to keep Gradle current. -
The agent attached to every
Testtask. After a fullcheck, the engine’sbuild/jacocodirectory containstest.exec,integrationTest.execandgoldenTest.exec;jacocoTestReportreads only the first one, because it is configured for thetesttask. To report the combined coverage of several suites, aJacocoReporttask of your own has to be given the execution data of all of them (not run here). The extension that the plugin adds to everyTesttask,jacoco { ... }in Groovy andextensions.configure<JacocoTaskExtension> { ... }in Kotlin, moves the.execfile (destinationFile) and controls the agent’s options.
The CSV report of the engine after the unit tests (INSTRUCTION and BRANCH are the interesting columns: every instruction of StationReader is covered, and 9 instructions of TideEngine are not, namely the branches of the switch for constituents other than the ones the tests use):
GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED
tidepool-engine,io.tidepool.engine,StationReader,0,92,0,4,0,10,0,3,0,1
tidepool-engine,io.tidepool.engine,TideEngine,9,51,5,3,5,8,5,2,0,1
The overall rule passed, because 143 of 152 instructions are covered, more than 80 percent. A stricter rule shows what a violation looks like. A second rule was added in a scratch copy, per class and for branches:
rule {
element = "CLASS"
limit {
counter = "BRANCH"
minimum = "0.90".toBigDecimal()
}
}
rule {
element = 'CLASS'
limit {
counter = 'BRANCH'
minimum = 0.90
}
}
> Task :tidepool-engine:jacocoTestCoverageVerification FAILED
[ant:jacocoReport] Rule violated for class io.tidepool.engine.TideEngine: branches covered ratio is 0.37, but expected minimum is 0.90
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-engine:jacocoTestCoverageVerification' (registered by plugin 'org.gradle.jacoco').
> A failure occurred while executing org.gradle.internal.jacoco.AntJacocoCheck
> Rule violated for class io.tidepool.engine.TideEngine: branches covered ratio is 0.37, but expected minimum is 0.90
(the Groovy DSL produced the same message). JaCoCo reports only the first violated rule, so a build with several broken rules shows them one at a time. The verification task attaches to check by the explicit dependsOn in the script: the manual explains that the task is not a dependency of check by default because it is not incremental (it declares no outputs), so every check would rerun it. Thresholds are a team decision: a rule on a new-code coverage, as SonarQube computes it, is more useful than a hard overall percentage that people satisfy with meaningless tests.
Aggregating test and coverage reports
In a multi-project build every project writes its own report, which is what you do not want on a CI summary page. Two plugins collect them: test-report-aggregation for test results and jacoco-report-aggregation for coverage. Both consume the variants that the test suites publish (their outgoing variants), so aggregation uses ordinary project dependencies, not file paths. The manual shows two arrangements: apply the plugin in a project that depends on the others anyway (an application or WAR project), or use a standalone project. The example is the second:
plugins {
base
`test-report-aggregation`
`jacoco-report-aggregation`
}
dependencies {
testReportAggregation(project(":tidepool-model"))
testReportAggregation(project(":tidepool-engine"))
jacocoAggregation(project(":tidepool-model"))
jacocoAggregation(project(":tidepool-engine"))
}
reporting {
reports {
create<AggregateTestReport>("testAggregateTestReport") {
testSuiteName = "test"
}
create<JacocoCoverageReport>("testCodeCoverageReport") {
testSuiteName = "test"
}
}
}
plugins {
id 'base'
id 'test-report-aggregation'
id 'jacoco-report-aggregation'
}
dependencies {
testReportAggregation project(':tidepool-model')
testReportAggregation project(':tidepool-engine')
jacocoAggregation project(':tidepool-model')
jacocoAggregation project(':tidepool-engine')
}
reporting {
reports {
testAggregateTestReport(AggregateTestReport) {
testSuiteName = 'test'
}
testCodeCoverageReport(JacocoCoverageReport) {
testSuiteName = 'test'
}
}
}
The two plugins add the configurations testReportAggregation and jacocoAggregation, on which the projects to aggregate are declared. The reporting block creates the reports and, with them, their tasks. Because this project does not apply the java plugin (it has no code), it applies no test suite of its own, and the reports have to be created manually; the plugins create them automatically (testAggregateTestReport and testCodeCoverageReport, named after the suite) in a project that applies java. The testSuiteName selects which suite of the dependencies is collected, so the integrationTest results would be a second report. Running the two report tasks:
./gradlew --configuration-cache :tidepool-reports:testAggregateTestReport :tidepool-reports:testCodeCoverageReport
> Task :tidepool-model:test
> Task :tidepool-engine:test
StationReaderTest > parsesManyStations() PASSED
...
TideEngineTest > pureM2StartsAtItsAmplitude() PASSED
> Task :tidepool-reports:testAggregateTestReport
> Task :tidepool-engine:jacocoTestReport
> Task :tidepool-model:jacocoTestReport
> Task :tidepool-reports:testCodeCoverageReport
BUILD SUCCESSFUL in 1s
13 actionable tasks: 13 executed
(compilation tasks are left out). The reports are in the aggregating project: tidepool-reports/build/reports/tests/test/aggregated-results/index.html lists the test classes of both projects (io.tidepool.engine.StationReaderTest, io.tidepool.engine.TideEngineTest and io.tidepool.model.HeightTest; eleven tests, nine of the engine and two of the model, none failed), and tidepool-reports/build/reports/jacoco/testCodeCoverageReport/ contains an HTML tree with the packages of both projects and an XML file. The aggregation ran the tests of the dependencies when needed, since the results are task outputs that the variants point to. To get a report even when tests fail, run the report tasks with --continue. The test report aggregation of 9.3 shows overlapping test sources (the same class tested in two projects) as separate tabs. The aggregation plugins were described as incubating in the 8.13 upgrade notes, which also changed how suites with several targets are aggregated.
Static checks: Checkstyle and PMD
The book’s chapter on code quality covers JaCoCo, Checkstyle, PMD and Sonar. Of the static analysis tools of that era, Gradle still ships checkstyle and pmd as core plugins (FindBugs and JDepend were deprecated in 5.0 and removed in 6.0; SpotBugs is the third-party successor, with its own plugin id). Each plugin adds one task per source set (checkstyleMain, checkstyleTest, pmdMain, …) and attaches them to check. A scratch project (legacy, extended by one production class) showed both:
plugins {
`java-library`
checkstyle
pmd
}
pmd {
ruleSets = listOf("category/java/bestpractices.xml", "category/java/errorprone.xml")
isConsoleOutput = true
}
plugins {
id 'java-library'
id 'checkstyle'
id 'pmd'
}
pmd {
ruleSets = ['category/java/bestpractices.xml', 'category/java/errorprone.xml']
consoleOutput = true
}
Checkstyle reads config/checkstyle/checkstyle.xml by default; this file was used:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<module name="NewlineAtEndOfFile"/>
<module name="TreeWalker">
<module name="UnusedImports"/>
<module name="MissingJavadocType"/>
</module>
</module>
The class under check has an unused import, no class Javadoc, an unused private field and an empty catch:
package legacy;
import java.util.List;
public class Tides {
private int never;
public static double twice(double metres) {
try {
return metres * 2;
} catch (RuntimeException e) {
}
return 0;
}
}
./gradlew checkstyleMain
> Task :checkstyleMain FAILED
[ant:checkstyle] [ERROR] /work/tidepool/src/main/java/legacy/Tides.java:3:8: Unused import - java.util.List. [UnusedImports]
[ant:checkstyle] [ERROR] /work/tidepool/src/main/java/legacy/Tides.java:5:1: Missing a Javadoc comment. [MissingJavadocType]
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkstyleMain' (registered by plugin 'org.gradle.checkstyle').
> A failure occurred while executing org.gradle.api.plugins.quality.internal.CheckstyleInvoker
> Checkstyle rule violations were found. See the report at: file:///work/tidepool/build/reports/checkstyle/main.html
Checkstyle files with violations: 1
Checkstyle violations by severity: [error:2]
./gradlew pmdMain
> Task :classes UP-TO-DATE
/work/tidepool/src/main/java/legacy/Tides.java:6: UnusedPrivateField: Avoid unused private fields such as 'never'.
/work/tidepool/src/main/java/legacy/Tides.java:11: EmptyCatchBlock: Avoid empty catch blocks
/work/tidepool/src/main/java/legacy/Tides.java:11: AvoidCatchingGenericException: Avoid catching RuntimeException in try-catch block
> Task :pmdMain FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':pmdMain' (registered by plugin 'org.gradle.pmd').
> A failure occurred while executing org.gradle.api.plugins.quality.internal.PmdInvoker
> 3 PMD rule violations were found. See the report at: file:///work/tidepool/build/reports/pmd/main.html
(only the lines that matter are shown; in both runs the violations are printed to the console, Checkstyle by its Ant task and PMD because of consoleOutput). Both ran in both DSLs with the same result. Notes: Checkstyle’s version in Gradle 9.8.1 resolved to 10.24.0 and PMD’s to 7.24.0 (see the checkstyle and pmd configurations of the dependencies report; toolVersion changes them); the PMD ruleset names used are the PMD 7 categories (category/java/bestpractices.xml); the Checkstyle and PMD reports are HTML and XML, and since Gradle 9.4 PMD also writes CSV, Code Climate and SARIF reports, configured on the task (tasks.pmdMain { reports { sarif.required = true } }) and not on the pmd extension. Violations fail the build by default; ignoreFailures (both plugins) and maxWarnings and maxErrors (Checkstyle) relax that (not run here). SonarQube is a service and is out of scope here; its Gradle integration is the org.sonarqube plugin, which reads the JaCoCo XML reports above, and the legacy sonar plugin of the book was removed in Gradle 3.0.
Classic mistakes
-
The integration suite is not part of
check. Fix:tasks.check { dependsOn(testing.suites.named("integrationTest")) }. The dry run above proves it. -
JUnit 4 tests vanish after a move to Jupiter. The
junit-vintage-engineis missing; only when no test is discovered does Gradle 9 fail the task. -
Failed to load JUnit Platformon a hand-wiredTesttask: the launcher is not on the runtime classpath. Use test suites, which add it, or addjunit-platform-launcher. -
A
Testtask registered without configuration runs nothing since 9.0 and says onlyNO-SOURCE. -
A red test stays hidden with
ignoreFailures = trueplus the up-to-date check, until--rerun. -
Tests that depend on the working directory. The worker’s working directory is the project directory, not the root; the example passes the data location as a system property computed from the project layout instead of hard-coding
../. -
Sharing state between parallel forks: files in a fixed location, fixed ports, a shared database.
-
Shipping test code in
maininstead of using test fixtures. -
Coverage thresholds as the goal: a violation rule with an overall percentage rewards trivial tests; per-class rules on branches, as above, or new-code rules from a quality server catch more.
-
Reading project properties inside a test task action (
project.property) breaks the configuration cache; use providers (providers.gradleProperty) at configuration time, as in the-Pfastexample. -
Timing claims from a first run. The 21 s of the first
goldenTestrun include building the projects and the daemon start; compare runs only after a warm-up.
Configuration cache
Every build script of this page was run with --configuration-cache and stored the entry without problems, and a second invocation reused it. What makes test builds compatible: only task properties and providers are read at configuration time (the system property of Test uses layout.projectDirectory, not a File captured from project); the JVM Test Suite plugin, JaCoCo, Checkstyle, PMD and the aggregation plugins are compatible, which is also true of the Test task. With the cache on, tasks of one project can run in parallel, which the page’s clean check output shows through the interleaved order of independent tasks. Incompatible things to avoid in test configuration: project in doFirst/doLast of a test task, a TestListener that holds on to Project, and build scripts that read environment variables at configuration time without declaring them. The configuration cache is on page Configuration Cache & Isolated Projects; a test task is cacheable by the build cache (see Build Cache) only if the tests are deterministic and do not depend on undeclared inputs: pass the inputs through inputs.file(...), inputs.property(...) or systemProperty with a provider, not by reading them in the test.
Gradle 8.14 and Gradle 9
The column for 8.14 comes from the official documentation and release notes (not run here); the column for 9.8.1 is what was run above, or the 9.x notes.
| Topic | Gradle 8.14 | Gradle 9.8.1 |
|---|---|---|
JUnit Platform launcher |
Loaded from the Gradle distribution when not declared, a behavior deprecated in 8.x ("automatic test framework implementation dependencies"); test suites declare it automatically. |
Must be on the test runtime classpath of a hand-wired |
|
A |
The convention is removed; such a task ends |
No test discovered |
A deprecation warning in 8.x for the successful run without executed tests. |
The task fails ( |
Default testing framework versions of test suites |
Older than the 9.0 defaults. |
JUnit Jupiter 5.12.2, TestNG 7.11.0, Spock 2.3 (9.0); pass a version to use a newer one, as the page does with JUnit 6.1.3. |
|
Removed in 8.13: the attribute is no longer used; aggregation selects suites by name. |
Not available. |
Tests on JVM 6 or 7 |
Deprecated in 8.x. |
An error since 9.0.0. The daemon needs Java 17 or later; test workers can run on an older JDK through |
JaCoCo |
0.8.13 (8.14). |
0.8.15, the default resolved here. |
HTML test report |
The classic report: tests grouped by package and class, output merged per class, aggregate report as one list. |
Detailed report with nested classes, parameterized-test suites and per-test output (9.3), sortable columns (9.6) and test metadata tabs (9.4). |
Non-class-based tests, |
Not available; workarounds with empty classes. |
Gradle 9.4.0, incubating. |
Test and JaCoCo report aggregation |
Incubating, with the changes of 8.13. |
Run above; overlapping sources as tabs (9.3). |
|
The |
Incubating option, run above. |
|
Neither property is in the 8.14 DSL reference. |
Both are in the 9.8.1 reference: the first since 9.0, the second since 9.4. |
Framework start-up failures |
Could be hidden by the logging granularity. |
Always printed (9.7.0). |
PMD report formats |
XML and HTML. |
CSV, Code Climate and SARIF as well (9.4.0). |
The same in Ant and Maven
| Concern | Ant | Maven | Gradle |
|---|---|---|---|
Run unit tests |
|
Surefire, bound to the |
The |
The test classpath and JVM |
A |
The test scope, the Surefire |
|
Select tests |
|
|
|
Integration tests |
A second compile and run target |
Failsafe, bound to |
A test suite attached to |
Reports |
|
|
HTML and XML in |
Coverage |
JaCoCo ant tasks ( |
|
The |
Test helpers shared between modules |
A shared JAR or directory by hand |
A |
|
Static checks |
Checkstyle and PMD ant tasks |
|
The |
The deeper comparison lives on the sibling pages: Testing with JUnit & junitlauncher, Maven Testing with Surefire, Maven Integration Testing with Failsafe and Maven Site & Reporting for the quality reports. The worked Maven setup of the Surefire, Failsafe, JaCoCo, Checkstyle and PMD plugins in a Spring Boot project is in Spring Boot Maven Quality Plugins, and the overall comparison is in Comparing Ant, Maven and Gradle. What carries over when a Maven build moves to Gradle is on Migrating from Maven.
Where the books are outdated
Gradle in Action (Muschko, Manning 2014, Gradle 1.7) devotes its testing chapter to the test pyramid, to JUnit, TestNG and Spock, to forking and parallel execution, to logging, filtering and listeners, to reports, and to integration and functional tests with hand-made source sets and tasks. The concepts survive and the mechanics changed:
-
Integration tests with a hand-made source set, configurations and
Testtask are replaced by the JVM Test Suite plugin (7.3); the hand-made form still works and is shown on Gradle Java Projects. -
compile,testCompile,runtimeandtestRuntimeare gone since 7.0; useimplementation,testImplementation,runtimeOnlyandtestRuntimeOnly. -
The book’s JUnit is JUnit 4 and the old runner. JUnit 5 support arrived in 4.6, JUnit 6 is the current line here, and the Vintage engine bridges old tests.
-
The book’s quality chapter covers JaCoCo, Checkstyle, PMD and Sonar. JaCoCo, Checkstyle and PMD remain core plugins; FindBugs and JDepend, core plugins of the era, were removed in 6.0; the legacy
sonarplugin was removed in 3.0 and replaced by SonarQube’s ownorg.sonarqubeplugin; the Cobertura plugin was third party and unmaintained. -
The book’s
ProjectBuilder-only tests of plugins are replaced by TestKit’sGradleRunner(see Testing & Publishing Plugins). -
The book’s test listeners are one way to observe results; for results consumed by CI, the XML files and the aggregation plugins are the supported path, and in a build with the configuration cache on, listeners that capture the project are a problem (see above).
-
The book predates the configuration cache, version catalogs, toolchains and
javaLauncher; the page numbers of the book were not checked, and the statements follow the research notes behind this section.
Where to go next
-
Gradle Java Projects for source sets and the hand-made form of what the test suites do.
-
Gradle Toolchains for the JDK that runs the tests.
-
Publishing for what happens to test fixtures when a library is published.
-
Build Cache and Performance Profiling & Build Scans for faster test builds and for the test timings in a scan.
-
Best Practices & Troubleshooting for the troubleshooting page of the manual that covers test failures.
-
Testing & Publishing Plugins for TestKit.