Gradle: sub-project test dependencies in multi-project builds

I’am currently working on a project where we try to move an ant based build to gradle. One task is to make the tests build and run from gradle. The build creates multiple artifacts and there are test dependencies between the sub-projects which gradle does not handle out of the box.

Let’s say we have a multi-project build with Project B depending on Project A. B does not only have a compile dependency on A but also a test dependency. The tests in B depend on a couple of test helper classes from A.

Handling this with gradle was not as straight forward as I had hoped. In the end it wasn’t very difficult but it took me some effort to understand the details. This may be completely obvious to you. :-)

There are a couple of possibilities:

the naive approach

The dependency for building of B is easy:

build.gradle:

dependencies {
    compile project (':A')
}

This will add the jar artifact from A as a dependency to project B. We can confirm this by using the following snippet to print the compile configuration:

configurations.compile.each {
    println "compile: $it"
}  

which will print:

compile: <path>/ProjectA/build/libs/ProjectA.jar

So the naive approach is to change the dependencies to:

build.gradle:

dependencies {
    compile project (':A')
    testCompile project (':A')
}

But this does not work. Printing testCompile with:

configurations.testCompile.each {
    println "testCompile: $it"
}  

still prints:

testCompile: <path>/ProjectA/build/libs/ProjectA.jar
...

only. Adding testCompile didn’t add anything to the dependencies. When we print the testCompile dependencies without the testCompile line we get the same output. testCompile extends compile and it doesn’t add an artifact we could depend on.

I found two solutions on stackoverflow.

a simple solution, depending on the test output paths

The simpler solution adds a dependency on the test sourceset output:

dependencies {
    ...
    testCompile project(':A').sourceSets.test.output
}

Ok ….? What’s this?

We use sourceSets to group source files. In this case we depend on the output of the test sources group (by default: src/test/java and src/test/resources). The output contains the path to the compiled test sources and resource files.

Let’s see what we get printed for testCompile now:

testCompile: <path>/ProjectA/build/classes/test
testCompile: <path>/ProjectA/build/resources/test
testCompile: <path>/ProjectA/build/libs/LearnA.jar

Looks good and it works. :-)

What I dislike about this solution is that we explicitly depend on some internals from B. It would be nice if we could hide this detail from A. Another issue is handling transitive test dependencies of A. We do not want to handle that in B. When we depend on the test code from A, A should take care of its transitive test dependencies as it does for a normal jar artifact.

improved solution, using a configuration

We can hide the internals of A by creating a new configuration in A

build.gradle in A:

configurations {
    testOutput
}

dependencies {
    testOutput sourceSets.test.output
}

… and adding the dependency on this configuration in B:

build.gradle in B:

dependencies {
    ...
    testCompile project(path: ':A', configuration: 'testOutput')
}

A configuration is simply a group of dependencies. We create a new one using the configurations object and can then add dependencies to it. To depend on it in B we pass a configuration parameter to the project method.

I like this one because using the testOutput configuration we have created something like an interface to the test output. The configuration is also used in the next solution.

improved solution, depending on a test jar

The second solution from stackoverflow creates a test jar from the test sources and uses a configuration to handle the dependencies.

build.gradle in A:

task jarTest (type: Jar) {
    from sourceSets.test.output
    classifier = 'test'
}

configurations {
    testOutput
}

artifacts {
    testOutput jarTest
}

The jarTest task builds the jar from the test outputs adding a ‘test’ to the jar name (there are more properties to customize the jar name). Then we create the configuration. We have seen this in the previous solution. Last step is to add the jar artifact to the configuration.

When someone asks for testOutput gradle will run the jarTest task.

The dependency configuration in B does not change:

build.gradle in B:

dependencies {
    ...
    testCompile project(path: ':A', configuration: 'testOutput')
}

When we now look at the testCompile output from our snippet we see the production and the test jar:

testCompile: <path>/ProjectA/build/libs/ProjectA.jar
testCompile: <path>/ProjectA/build/libs/ProjectA-test.jar

improving jarTest

Although gradle seems to always build the test stuff before creating the jar (the from triggers building the test code?) there is no explicit dependency on a test build task. Looking at the task dependencies of the java plugin jar depends on classes, so it is probably a good idea to add a depends on on testClasses to the jarTest:

task jarTest (type: Jar, dependsOn: testClasses) {
    from sourceSets.test.output
    classifier = 'test'
}

handling transitive dependencies

Next let us assume that ProjectA-test.jar depends on another project: T. With the current solution running the tests of B will fail because the testRuntime classpath won’t contain T.

First we need T to build A. That’s easy we just add a testCompile dependency:

    dependencies {
        testCompile project (':T')
    }

But this doesn’t help at runtime, B depends on the testOutput configuration which doesn’t know anything about T. So let’s add the dependency on T:

    dependencies {
        testCompile project (':T')
        testOutput project (':T')
    }

testOutput is a dependency configuration like testCompile so we can add our dependency on T as we did on testCompile.

Printing testRuntime when we run B

configurations.testRuntime.each {
    println "testRuntime: $it"
}  

we can check that it contains:

testRuntime: <path>/ProjectT/build/libs/ProjectT.jar

.. and it doesn’t fail anymore. Nice :-)

cleaning up duplication

There is just one small issue left: by adding the T dependency to testCompile and testOutput we have created a little bit of duplication.

Since testOutput depends on building the test code it would be helpful to tell gradle that testOutput should re-use the configuration from testCompile. Then we wouldn’t need to explicitly list the T dependency on testOutput.

We can by defining testOutput like this:

configurations {
    testOutput.extendsFrom (testCompile)
}

We can remove the explicit T dependency on testOutput (removing the duplication) now because we will get it from testCompile.

final solution

Our final solution needs more code than just adding a dependency on the test output paths but it will make it easier to
handle the test dependencies and their transitive dependencies in a multi-project build.

Here is the final code:

build.gradle in A:

configurations {
    testOutput.extendsFrom (testCompile)
}

dependencies {
    testCompile project (':T')
}

task jarTest (type: Jar, dependsOn: testClasses) {
    from sourceSets.test.output
    classifier = 'test'
}

artifacts {
    testOutput jarTest
}


build.gradle in B:

dependencies {
    ...
    testCompile project(path: ':A', configuration: 'testOutput')
}   

conclusion

After playing around with this for a while it doesn’t look as magic as at first sight but there are still a few questions…

Gradle is really simple for builds that don’t need anything special but it is not so simple anymore if you want to do something a little bit different. Interesting is that the final solution does not have much code. That’s nice but it is still not very obious to implement it like this (.. at least to me).

My next step is to make a plugin from it so we do not have to duplicate that code into all our sub-projects :-)

Update: Nov 2015

Working code is available from my github page (it is slightly different than the version presented above, which may be the reason for the problems mentioned in the comments). It is a gradle project that creates a gradle plugin. I have also created a simple example project that I have run with gradle 2.2 up to 2.8 without problem. Hope that helps!

Update: Dec 2015

This is now available as a plugin from plugins.gradle.org.

Advertisements