Test your custom Task and Plugins with TestKit

You should test any custom tasks or plugins you create using Gradle TestKit.

Explanation

Gradle’s flexibility supports a natural evolution of custom types as they mature.

Creating new tasks and plugins directly in a build.gradle(.kts) file is a great way to prototype new functionality. As that functionality stabilizes, you should extract these definitions into buildSrc or a standalone plugin project for better reusability and maintainability. Once types exist outside of a single build file, you can easily write functional tests for them using TestKit.

A mature build should include functional tests for its custom types to ensure they behave as expected.

Example

Don’t Do This

The following build defines a custom task and a custom plugin that applies it within the build.gradle(.kts) file. The plugin adds multiple custom tasks that print a greeting using properties defined in a custom extension.

This is a common pattern for prototyping new functionality, but it lacks tests to verify the behavior of the custom types. The only way to verify that the task and plugin work as intended is to run the build manually and inspect the output.

build.gradle.kts
import java.time.Instant

interface MyExtension {
    val firstName: Property<String>
    val lastName: Property<String>
}

var greeter = "Hello"

@CacheableTask (1)
abstract class MyTask: DefaultTask() {
    @get:Input
    abstract val firstName: Property<String>
    @get:Input
    abstract val lastName: Property<String>
    @get:Input
    abstract val greeting: Property<String>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    private final val today = Instant.now() (2)

    @TaskAction
    fun run() {
        val output = outputFile.asFile.get()
        val result = "${greeting.get()}, ${firstName.get()} ${lastName.get()}, it's currently\n$today"
        println(result)
        output.writeText(result)
    }
}

abstract class MyPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create("myExtension", MyExtension::class.java)

        project.tasks.register<MyTask>("task1") {
            outputFile.convention(project.layout.buildDirectory.file("output1.txt"))
        }

        project.tasks.register<MyTask>("task2") {
            outputFile.convention(project.layout.buildDirectory.file("output2.txt"))
        }

        project.tasks.withType<MyTask>().configureEach {
            group = "Custom Tasks"
            firstName.convention(extension.firstName)
            lastName.convention(extension.firstName) (3)
            greeting.convention("Hi")
        }
    }
}

apply<MyPlugin>()

configure<MyExtension> {
    firstName = "John"
    lastName = "Smith"
}

tasks.named<MyTask>("task2") {
    greeter = "Bonjour" (4)
}
build.gradle
import java.time.Instant

interface MyExtension {
    Property<String> getFirstName()
    Property<String> getLastName()
}

def greeter = "Hello"

@CacheableTask (1)
abstract class MyTask extends DefaultTask {
    @Input
    abstract Property<String> getFirstName()
    @Input
    abstract Property<String> getLastName()
    @Input
    abstract Property<String> getGreeting()

    @OutputFile
    abstract RegularFileProperty getOutputFile()

    private final Instant today = Instant.now() (2)

    @TaskAction
    void run() {
        def output = outputFile.asFile.get()
        def result = "${greeting.get()}, ${firstName.get()} ${lastName.get()}, it's currently\n$today"
        println result
        output.text = result
    }
}

abstract class MyPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        def extension = project.extensions.create("myExtension", MyExtension)

        project.tasks.register("task1", MyTask) { task ->
            task.outputFile.convention(project.layout.buildDirectory.file("output1.txt"))
        }

        project.tasks.register("task2", MyTask) { task ->
            task.outputFile.convention(project.layout.buildDirectory.file("output2.txt"))
        }

        project.tasks.withType(MyTask).configureEach { task ->
            task.group = "Custom Tasks"
            task.firstName.convention(extension.firstName)
            task.lastName.convention(extension.firstName) (3)
            task.greeting.convention("Hi")
        }
    }
}

apply plugin: MyPlugin

myExtension {
    firstName = "John"
    lastName = "Smith"
}

tasks.named("task2", MyTask) {
    greeter = "Bonjour" (4)
}

In this case, there are several problems:

1 The Task is declared as cacheable: However, the task’s output differs depending on when it is run, so it should not be cached.
2 The current time is used as an undeclared input: Inputs to a task must be explicitly declared using the appropriate annotations, otherwise Gradle cannot track changes to them.
3 Error in task property wiring: The lastName property is linked to the firstName property on the extension, which is likely a mistake.
4 The wrong variable is assigned during task configuration: The greeter variable from the buildscript is mistakenly assigned instead of the task’s greeting property.

Do This Instead

In this updated version of the build, the custom types are defined in an included build-logic composite build, which now includes a basic set of functional tests using Gradle TestKit.

While these custom types are written in Java for demonstration purposes, they could just as easily be implemented in Groovy or Kotlin. Because they reside in a separate, complete Gradle build, they can be thoroughly tested using TestKit.

├── build-logic/
│   ├── src
│   │   ├── main
│   │   │   └── java
│   │   │       └── org
│   │   │           └── example
│   │   │               └── MyExtension.java
│   │   │               └── MyPlugin.java
│   │   │               └── MyTask.java
│   │   └── functionalTest
│   │       └── java
│   │           └── org
│   │               └── example
│   │                   └── MyPluginFunctionalTest.java
│   ├── build.gradle.kts
│   └── settings.gradle.kts
├── settings.gradle.kts
└── build.gradle.kts
├── build-logic/
│   ├── src
│   │   ├── main
│   │   │   └── java
│   │   │       └── org
│   │   │           └── example
│   │   │               └── MyExtension.java
│   │   │               └── MyPlugin.java
│   │   │               └── MyTask.java
│   │   └── functionalTest
│   │       └── java
│   │           └── org
│   │               └── example
│   │                   └── MyPluginFunctionalTest.java
│   ├── build.gradle
│   └── settings.gradle
├── settings.gradle
└── build.gradle

We’ve corrected the issues with the plugin and task from the previous example:

MyTask.java
package org.example;

import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import java.io.File;
import java.nio.file.Files;
import java.time.Instant;
import java.io.IOException;

@CacheableTask
public abstract class MyTask extends DefaultTask {
    @Input
    public abstract Property<String> getFirstName();
    @Input
    public abstract Property<String> getLastName();
    @Input
    public abstract Property<String> getGreeting();

    @OutputFile
    public abstract RegularFileProperty getOutputFile();

    @Input (1)
    public abstract Property<Instant> getToday();

    @TaskAction
    public void run() throws IOException {
        File output = getOutputFile().getAsFile().get();
        String result = String.format("%s, %s %s, it's currently\n%s", getGreeting().get(), getFirstName().get(), getLastName().get(), getToday().get());
        System.out.println(result);
        Files.writeString(output.toPath(), result);
    }
}
1 Today is a proper @Input: This allows Gradle’s UP-TO-DATE checking to properly consider it when rerunning the task.
MyPlugin.java
package org.example;

import org.gradle.api.Plugin;
import org.gradle.api.Project;
import java.time.Instant;

public abstract class MyPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        MyExtension extension = project.getExtensions().create("myExtension", MyExtension.class);

        project.getTasks().register("task1", MyTask.class, task -> {
            task.getOutputFile().convention(project.getLayout().getBuildDirectory().file("output1.txt"));
        });

        project.getTasks().register("task2", MyTask.class, task -> {
            task.getOutputFile().convention(project.getLayout().getBuildDirectory().file("output2.txt"));
        });

        project.getTasks().withType(MyTask.class).configureEach(task -> {
            task.setGroup("Custom Tasks");
            task.getFirstName().convention(extension.getFirstName());
            task.getLastName().convention(extension.getLastName()); (1)
            task.getGreeting().convention("Hi");
            task.getToday().convention(Instant.now()); (2)
        });
    }
}
1 Corrected typo in assignment: The last name convention set to the value of the last name from the extension.
2 Today is set to the same value on all tasks: Only calling Instant.now() once, rather than every time a task is created.

Writing and running the tests as described below helped identify the bugs in the plugin implementation.

We defined a functional test suite within the build-logic project. Because TestKit tests tend to be slower and more complex than unit tests, they are typically kept separate. Locating these tests in a dedicated functionalTest suite also clarifies their purpose for other developers.

build.gradle.kts
val functionalTest = testing.suites.register("functionalTest", JvmTestSuite::class) { (1)
    useJUnitJupiter()
    dependencies {
        implementation("commons-io:commons-io:2.16.1")
        implementation(project())
        implementation(gradleTestKit()) (2)
    }
}

tasks.check {
    dependsOn(functionalTest)
}

gradlePlugin {
    plugins {
        register("org.example.myplugin") {
            implementationClass = "org.example.MyPlugin"
        }
    }
    testSourceSets(functionalTest.get().sources) (3)
}
build.gradle
def functionalTest = testing.suites.register("functionalTest", JvmTestSuite) { (1)
    useJUnitJupiter()
    dependencies {
        implementation("commons-io:commons-io:2.16.1")
        implementation(project())
        implementation(gradleTestKit()) (2)
    }
}

tasks.check {
    dependsOn(functionalTest)
}

gradlePlugin {
    plugins {
        register("org.example.myplugin") {
            implementationClass = "org.example.MyPlugin"
        }
    }

    testSourceSets functionalTest.get().sources (3)
}
1 Define the new test suite: Creates a functionalTest JVM Test Suite.
2 Add TestKit dependency: This contains the GradleRunner class we’ll use to write tests.
3 Make the java-gradle-plugin aware of the new test suite: Now the usable plugin source from the project’s main production code will be available to these tests.

With this setup in place, we can write functional tests for our plugin in the build-logic/src/functionalTest/java directory. You can run ./gradlew :build-logic:functionalTest or ./gradlew :build-logic:check from the root project directory to execute these tests.

By default, tests within the included build-logic build are not executed when you run tests in the root project. Because the root project only requires the build artifacts from build-logic, Gradle will build the project without running its internal tests. To run them, you must explicitly invoke the tasks: ./gradlew :build-logic:check.

In the functional test class, we use TestKit to initialize a temporary Gradle project, apply our plugin, and verify that it behaves as expected.

MyPluginFunctionalTest.java
package org.example;

import org.gradle.testkit.runner.BuildResult;
import org.gradle.testkit.runner.GradleRunner;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.nio.file.Files;
import java.io.File;
import java.io.IOException;

import org.apache.commons.io.FileUtils;

import static org.gradle.testkit.runner.TaskOutcome.FROM_CACHE;
import static org.gradle.testkit.runner.TaskOutcome.SUCCESS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class MyPluginFunctionalTest {
    @TempDir File testProjectDir;
    private File settingsFile;
    private File buildFile;
    private File cacheDir = new File("build-cache");

    @BeforeEach
    public void setup() throws IOException {
        settingsFile = new File(testProjectDir, "settings.gradle");
        Files.writeString(settingsFile.toPath(), "rootProject.name = 'test-project'");

        buildFile = new File(testProjectDir, "build.gradle");
    }

    @Test
    public void testTaskRegistration() throws IOException { (1)
        String buildFileContent = """
            plugins {
                id("org.example.myplugin")
            }
        """;
        Files.writeString(buildFile.toPath(), buildFileContent);

        BuildResult result = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withPluginClasspath()
            .forwardOutput()
            .withArguments("tasks", "--all")
            .build();

        assertContainsIgnoringEol("""
            Custom Tasks tasks
            ------------------
            task1
            task2
            """,
            result.getOutput()
        );
    }

    @Test
    public void testTaskExecution() throws IOException { (2)
        File outputFile = new File(testProjectDir, "build/output.txt");

        String buildFileContent = """
            plugins {
                id("org.example.myplugin")
            }

            myExtension {
                firstName = "John"
                lastName = "Smith"
            }

            tasks.task1 {
                outputFile = project.layout.buildDirectory.file("output.txt")
            }
        """;
        Files.writeString(buildFile.toPath(), buildFileContent);

        GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withPluginClasspath()
            .withArguments("task1")
            .build();

        String actual = Files.readString(outputFile.toPath());
        assertTrue(actual.startsWith("Hi, John Smith, it's currently"));
    }

    @Test
    public void testTaskDeterminism() throws IOException { (3)
        File outputFile = new File(testProjectDir, "build/output.txt");

        String buildFileContent = """
            plugins {
                id("org.example.myplugin")
            }

            myExtension {
                firstName = "John"
                lastName = "Smith"
            }

            tasks.task1 {
                outputFile = project.layout.buildDirectory.file("output.txt")
                today = ZonedDateTime.parse("2026-01-12T16:00:00-05:00").toInstant()
            }
        """;
        Files.writeString(buildFile.toPath(), buildFileContent);

        GradleRunner runner = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withPluginClasspath();

        runner.withArguments("task1").build();
        String output1 = Files.readString(outputFile.toPath());

        runner.withArguments("task1", "--rerun-tasks").build();
        String output2 = Files.readString(outputFile.toPath());

        assertEquals(output1, output2);
    }

    @Test
    public void testTaskCacheability() throws IOException { (4)
        String buildFileContent = """
            plugins {
                id("org.example.myplugin")
            }

            myExtension {
                firstName = "John"
                lastName = "Smith"
            }

            tasks.task1 {
                outputFile = project.layout.buildDirectory.file("output.txt")
                today = ZonedDateTime.parse("2026-01-12T16:00:00-05:00").toInstant()
            }
        """;
        Files.writeString(buildFile.toPath(), buildFileContent);

        GradleRunner runner = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withPluginClasspath()
            .forwardOutput();

        BuildResult result = runner.withArguments("--build-cache", "task1").build();
        assertEquals(SUCCESS, result.task(":task1").getOutcome());

        FileUtils.deleteDirectory(new File(testProjectDir, "build"));

        result = runner.withArguments("--build-cache", "task1").build();
        assertEquals(FROM_CACHE, result.task(":task1").getOutcome());
    }

    private static void assertContainsIgnoringEol(String expected, String actual) {
        assertTrue(normalizeEol(actual).contains(normalizeEol(expected)));
    }

    private static String normalizeEol(String s) {
        return s.replace("\r\n", "\n").replace("\r", "\n");
    }
}

Looking at the example tests here, you can see various techniques used to verify the plugin’s behavior against actual, ad-hoc Gradle builds defined within the tests:

1 testTaskRegistration: This test runs the tasks report and verifies the output.
2 testTaskExecution: This test runs the custom task and verifies its output file.
3 testTaskDeterminism: This test runs the build twice - forcing tasks to rerun the second time - to ensure the output is identical, which is necessary for caching.
4 testTaskCacheability: This test checks that when the task is run twice in a row, the second run is loaded from cache.

These examples only scratch the surface of what you can achieve with TestKit. For instance, a comprehensive test for cacheability should verify that changes to inputs correctly trigger re-execution and include tests for relocatability.

You can find more information in the Gradle TestKit documentation.