As of Gradle 4.0, the build tool fully supports caching plain Java projects. Built-in tasks for compiling, testing, documenting and checking the quality of Java code support the build cache out of the box.

Java compilation

Caching Java compilation makes use of Gradle’s deep understanding of compile classpaths. The mechanism avoids recompilation when dependencies change in a way that doesn’t affect their application binary interfaces (ABI). Since the cache key is only influenced by the ABI of dependencies (and not by their implementation details like private types and method bodies), task output caching can also reuse compiled classes if they were produced by the same sources and ABI-equivalent dependencies.

For example, take a project with two modules: an application depending on a library. Suppose the latest version is already built by CI and uploaded to the shared cache. If a developer now modifies a method’s body in the library, the library will need to be rebuilt on their computer. But they will be able to load the compiled classes for the application from the shared cache. Gradle can do this because the library used to compile the application on CI, and the modified library available locally share the same ABI.

Annotation processors

Compile avoidance works out of the box. There is one caveat though: when using annotation processors, Gradle uses the annotation processor classpath as an input. Unlike most compile dependencies, in which only the ABI influences compilation, the implementation of annotation processors must be considered as an input to the compiler. For this reason Gradle will treat annotation processors as a runtime classpath, meaning less input normalization is taking place there. If Gradle detects an annotation processor on the compile classpath, the annotation processor classpath defaults to the compile classpath when not explicitly set, which in turn means the entire compile classpath is treated as a runtime classpath input.

For the example above this would mean the ABI extracted from the compile classpath would be unchanged, but the annotation processor classpath (because it’s not treated with compile avoidance) would be different. Ultimately, the developer would end up having to recompile the application.

The easiest way to avoid this performance penalty is to not use annotation processors. However, if you need to use them, make sure you set the annotation processor classpath explicitly to include only the libraries needed for annotation processing. The section on Java compile avoidance describes how to do this.

Some common Java dependencies (such as Log4j 2.x) come bundled with annotation processors. If you use these dependencies, but do not leverage the features of the bundled annotation processors, it’s best to disable annotation processing entirely. This can be done by setting the annotation processor classpath to an empty set.

Unit test execution

The Test task used for test execution for JVM languages employs runtime classpath normalization for its classpath. This means that changes to order and timestamps in jars on the test classpath will not cause the task to be out-of-date or change the build cache key. For achieving stable task inputs you can also wield the power of filtering the runtime classpath.

Integration test execution

Unit tests are easy to cache as they normally have no external dependencies. For integration tests the situation can be quite different, as they can depend on a variety of inputs outside of the test and production code. These external factors can be for example:

  • operating system type and version,

  • external tools being installed for the tests,

  • environment variables and Java system properties,

  • other services being up and running,

  • a distribution of the software under test.

You need to be careful to declare these additional inputs for your integration test in order to avoid incorrect cache hits. For example, declaring the operating system in use by Gradle as an input to a Test task called integTest would work as follows:

build.gradle.kts
tasks.integTest {
    inputs.property("operatingSystem") {
        System.getProperty("os.name")
    }
}
build.gradle
tasks.named('integTest') {
    inputs.property("operatingSystem") {
        System.getProperty("os.name")
    }
}

Archives as inputs

It is common for the integration tests to depend on your packaged application. If this happens to be a zip or tar archive, then adding it as an input to the integration test task may lead to cache misses. This is because, as described in repeatable task outputs, rebuilding an archive often changes the metadata in the archive. You can depend on the exploded contents of the archive instead. See also the section on dealing with non-repeatable outputs.

Dealing with file paths

You will probably pass some information from the build environment to your integration test tasks by using system properties. Passing absolute paths will break relocatability of the integration test task.

build.gradle.kts
// Don't do this! Breaks relocatability!
tasks.integTest {
    systemProperty("distribution.location", layout.buildDirectory.dir("dist").get().asFile.absolutePath)
}
build.gradle
// Don't do this! Breaks relocatability!
tasks.named('integTest') {
    systemProperty "distribution.location", layout.buildDirectory.dir('dist').get().asFile.absolutePath
}

Instead of adding the absolute path directly as a system property, it is possible to add an annotated CommandLineArgumentProvider to the integTest task:

build.gradle.kts
abstract class DistributionLocationProvider : CommandLineArgumentProvider {  (1)
    @get:InputDirectory
    @get:PathSensitive(PathSensitivity.RELATIVE)  (2)
    abstract val distribution: DirectoryProperty

    override fun asArguments(): Iterable<String> =
        listOf("-Ddistribution.location=${distribution.get().asFile.absolutePath}")  (3)
}

tasks.integTest {
    jvmArgumentProviders.add(
        objects.newInstance<DistributionLocationProvider>().apply {  (4)
            distribution = layout.buildDirectory.dir("dist")
        }
    )
}
build.gradle
abstract class DistributionLocationProvider implements CommandLineArgumentProvider {  (1)
    @InputDirectory
    @PathSensitive(PathSensitivity.RELATIVE)  (2)
    abstract DirectoryProperty getDistribution()

    @Override
    Iterable<String> asArguments() {
        ["-Ddistribution.location=${distribution.get().asFile.absolutePath}"]  (3)
    }
}

tasks.named('integTest') {
    jvmArgumentProviders.add(
        objects.newInstance(DistributionLocationProvider).tap {  (4)
            distribution = layout.buildDirectory.dir('dist')
        }
    )
}
1 Create a class implementing CommandLineArgumentProvider.
2 Declare the inputs and outputs with the corresponding path sensitivity.
3 asArguments needs to return the JVM arguments passing the desired system properties to the test JVM.
4 Add an instance of the newly created class as JVM argument provider to the integration test task.[1]

Ignoring system properties

It may be necessary to ignore some system properties as inputs as they do not influence the outcome of the integration tests. In order to do so, add a CommandLineArgumentProvider to the integTest task:

build.gradle.kts
abstract class CiEnvironmentProvider : CommandLineArgumentProvider {
    @get:Internal  (1)
    abstract val agentNumber: Property<String>

    override fun asArguments(): Iterable<String> =
        listOf("-DagentNumber=${agentNumber.get()}")  (2)
}

tasks.integTest {
    jvmArgumentProviders.add(
        objects.newInstance<CiEnvironmentProvider>().apply {  (3)
            agentNumber = providers.environmentVariable("AGENT_NUMBER").orElse("1")
        }
    )
}
build.gradle
abstract class CiEnvironmentProvider implements CommandLineArgumentProvider {
    @Internal  (1)
    abstract Property<String> getAgentNumber()

    @Override
    Iterable<String> asArguments() {
        ["-DagentNumber=${agentNumber.get()}"]  (2)
    }
}

tasks.named('integTest') {
    jvmArgumentProviders.add(
        objects.newInstance(CiEnvironmentProvider).tap {  (3)
            agentNumber = providers.environmentVariable("AGENT_NUMBER").orElse("1")
        }
    )
}
1 @Internal means that this property does not influence the output of the integration tests.
2 The system properties for the actual test execution.
3 Add an instance of the newly created class as JVM argument provider to the integration test task.[1]

1. The CommandLineArgumentProvider in this example is implemented as a managed type.