Testing Gradle plugins happens at two levels. Integration tests run a real Gradle build against a real project directory and check what came out. Unit tests exercise your logic directly, without a build at all.
Our plugin as written can only be tested at the integration level, because everything it does is
tied to a Project. Tutorial 4 restructures it so that unit
tests become possible. For now we will cover:
- How to set up TestKit.
- How to create a test Gradle project.
- How to include your plugin in a test build.
- How to inspect the results of that build.
Gradle ships a test kit built for exactly
this. If you applied java-gradle-plugin in the previous tutorial, most of the wiring is already
done for you: it adds the gradleTestKit() dependency to your test compile classpath, and it
generates the metadata that tells GradleRunner where your plugin's classes live.
So the whole build script addition is your test framework and nothing else:
dependencies {
testImplementation platform('org.junit:junit-bom:5.11.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}If you are following an older tutorial, you may have seen a
createClasspathManifesttask that writes aplugin-classpath.txtfile, plus atestRuntime files(createClasspathManifest)dependency. That was the workaround beforejava-gradle-pluginlearned to do it itself. It is no longer needed, andtestRuntimewas removed from Gradle entirely. Delete both; callwithPluginClasspath()with no arguments instead.
An integration test needs a project to build. We keep two of them under testProjects/, checked
into the repository so you can also run them by hand while developing.
testProjects/simpleProject/settings.gradle:
rootProject.name = 'simpleProject'testProjects/simpleProject/build.gradle:
plugins {
id 'io.github.intisy.myplugin'
}Note there is no version on that plugin id, and no repository declaration. withPluginClasspath()
injects the plugin directly onto the build's class path, so there is nothing to resolve.
package io.github.intisy;
import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.GradleRunner;
import org.gradle.testkit.runner.TaskOutcome;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TestRealBuild {
private static final Path FIXTURES = Path.of(System.getProperty("user.dir"), "testProjects");
@TempDir
Path projectDir;
private GradleRunner runnerFor(String fixture, String task) throws IOException {
copyDirectory(FIXTURES.resolve(fixture), projectDir);
return GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withPluginClasspath()
.withArguments(task, "--configuration-cache", "--stacktrace");
}
@Test
void dealWithItPrintsTheGreeting() throws IOException {
BuildResult result = runnerFor("simpleProject", "dealwithit").build();
assertEquals(TaskOutcome.SUCCESS, result.task(":dealwithit").getOutcome());
assertTrue(result.getOutput().contains("(•_•) ( •_•)>⌐■-■ (⌐■_■)"));
}
}Three things here are worth calling out.
The fixture is copied into a @TempDir rather than built in place. Running Gradle inside
testProjects/simpleProject leaves a build/ directory and a .gradle/ cache behind in your
source tree. Older versions of this tutorial dealt with that by deleting those directories in
setUp and tearDown, which works right up until a test fails and leaves the mess anyway. Copying
into a temporary directory means every test starts from a known state and cleans itself up.
withPluginClasspath() takes no arguments. It reads the metadata generated by
java-gradle-plugin. There is no file for you to build or maintain.
Every run passes --configuration-cache. This is a cheap and very effective guard. Gradle's
configuration cache fails the
build if a task reaches back into the Project object while it is executing, which is the most
common structural mistake in plugin code. Asking for it in the test suite means you find out
immediately rather than from a user's bug report.
We assert SUCCESS, not UP_TO_DATE. Because the greeting lives in a doLast block, the task
has work to do and reports SUCCESS. If you had written the println at configuration time as
warned about in tutorial 1, the task would have no actions and report UP_TO_DATE, while the text
appeared in the output of every unrelated build.
BuildResult gives you two useful handles:
result.task(":taskname").getOutcome()returns theTaskOutcome:SUCCESS,UP_TO_DATE,SKIPPED,FAILED,FROM_CACHE,NO_SOURCE.result.getOutput()returns the full console output as aString.
For a task that produces files, assert on the files rather than the log. Because the build ran in
projectDir, the output is right where you would expect:
@Test
void myTaskWritesTheDefaultContent() throws IOException {
BuildResult result = runnerFor("simpleProject", "mytask").build();
assertEquals(TaskOutcome.SUCCESS, result.task(":mytask").getOutcome());
assertEquals("¯\\_(ツ)_/¯",
Files.readString(projectDir.resolve("build/myfile.txt")));
}To assert on a build that is supposed to fail, call buildAndFail() instead of build().
Integration tests are slow, because each one starts a Gradle build. They are also the only way to verify that your plugin behaves correctly when a real build script applies it, so you will always want some. The trick is to keep the number small and push detail down into unit tests.
In the next step we will look at a better way to define tasks, so that a plugin can grow past a single file.