Incremental testing for Gradle multi-projects

patxibocos

Patxi Bocos

Posted on July 22, 2019

Incremental testing for Gradle multi-projects

time
Application modularization is something pretty common on Android, even Google talked about it in last I/O. Whether you use it just to separate different domains or to implement a clean architecture with multiple layers, you will end up with multiple Gradle subprojects. One of the benefits of modularizing your app is accelerating the build times, but you can go further.

Each of the subprojects will probably have some tests (hopefully) which will be ran both on your local machine and in some CI system. When someone raises a PR on project’s Git repository, some workflow will be triggered to run the tests, measure coverage, run linters, etc. If your app is based on a large codebase with several tests, this can take long. Can we do something to avoid running all the tests everytime? Maybe a PR is modifying a single class on a subproject, and we don’t need to run tests on every subproject.

In order to improve this, we are going to build a Gradle task which does the following:

  • Detect which files have been added/modified/removed in some branch, compared to another one (base).
  • Identify which Gradle subprojects belong those files to.
  • For each of the subprojects, traverse recursively every other subprojects to collect which ones are depending on them.
  • Run test tasks for all the affected projects.

Changed files between branches

This basically consists on doing a git diff. To run this we are taking the support from JGit library:

implementation("org.eclipse.jgit:org.eclipse.jgit:5.4.0.201906121030-r")
Enter fullscreen mode Exit fullscreen mode

Given some Gradle project object and a branch name to make the diff with, we can build some JGit’s Git instance object:

val git: Git = Git.open(File(rootProject.projectDir.path))
Enter fullscreen mode Exit fullscreen mode

This object contains a diff() function which returns a DiffCommand and needs to be setup to compare both old and new trees. This is done calling setOldTree and setNewTree functions respectively. Those functions expect an AbstractTreeIterator which we must build as follows:

private fun getGitReferenceTree(repository: Repository, ref: String): AbstractTreeIterator {
    val head = repository.exactRef(ref)
    RevWalk(repository).use { walk ->
        val commit = walk.parseCommit(head.objectId)
        val tree = walk.parseTree(commit.tree.id)
        val treeParser = CanonicalTreeParser()
        repository.newObjectReader().use { reader -> treeParser.reset(reader, tree.id) }
        walk.dispose()
        return treeParser
    }
}
Enter fullscreen mode Exit fullscreen mode

This code has been adapted from jgit-cookbook. For a given repository and a Git ref, it will resolve the ref to get the head, then retrieve both commit and tree to finally build a CanonicalTreeParses pointing to the branch.

internal fun getDiffFilePaths(rootProject: Project, branchName: String): Iterable<String> {
    val git = Git.open(File(rootProject.projectDir.path))
    val masterTree = getGitReferenceTree(git.repository, "refs/remotes/origin/master")
    val branchTree = getGitReferenceTree(git.repository, "refs/remotes/origin/$branchName")
    val diffEntries = git.diff().setOldTree(masterTree).setNewTree(branchTree).call()
    return diffEntries.flatMap { diffEntry -> listOf(diffEntry.oldPath, diffEntry.newPath) }
}
Enter fullscreen mode Exit fullscreen mode

The method above will return both old and new paths for each of the entries, so if a file is moved from subproject A to subproject B, then both paths relative to each subprojects will be retrieved.

Gradle subprojects affected by diff file paths

Given the relative path of a list of files, it is pretty easy to get the Gradle projects those files belong to. We just need to filter the projects whose projectDir is contained at the beginning of any of the file paths:

private fun getProjectsForFilePaths(rootProject: Project, filePaths: Iterable<String>): Iterable<Project> =
    rootProject.subprojects.filter { subproject ->
        filePaths.find { filePath ->
            filePath.startsWith(subproject.projectDir.toRelativeString(rootProject.projectDir))
        } != null
    }
Enter fullscreen mode Exit fullscreen mode

Calculate dependant projects

As any of those projects could be a dependency of any other project, we are looking up for every other project that could be depending on any of those (recursively).

For a given project, and a list of all projects, we will calculate the dependants filtering the projects which contains a dependency of type project for the given one under the configuration api or implementation:

private fun getDependantProjects(allProjects: Iterable<Project>, dependencyProject: Project): Iterable<Project> {
    val dependants = allProjects.filter { subproject ->
        try {
            val implementationDependencies = subproject.configurations.getByName("implementation").dependencies
            val apiDependencies = subproject.configurations.getByName("api").dependencies
            (implementationDependencies + apiDependencies).filterIsInstance<DefaultProjectDependency>().find { dependency ->
                dependency.dependencyProject == dependencyProject
            } != null
        } catch (exception: UnknownConfigurationException) {
            false
        }
    }
    return dependants + dependants.flatMap { getDependantProjects(allProjects, it) }
}
Enter fullscreen mode Exit fullscreen mode

This is returning all the projects which are directly depending on a given project. To make this recursive, we just need to call the function and flattening the results:

return dependants + dependants.flatMap {
    getDependantProjects(allProjects, it)
}
Enter fullscreen mode Exit fullscreen mode

Combining this with our previous function, the result will be:

fun projectsAffectedByBranch(branchName: String, rootProject: Project): Collection<Project> {
    val diffFilePaths = getDiffFilePaths(rootProject, branchName)
    val affectedProjects = getProjectsForFilePaths(rootProject, diffFilePaths)
    return affectedProjects.flatMap { getDependantProjects(rootProject.subprojects, it) + it }.distinct()
}
Enter fullscreen mode Exit fullscreen mode

We are flattening again as the result of calling getDependantProjects is returning also a list. Calling distinct() is needed as we don’t want duplicates.

Running test tasks on the affected projects

Once we have a list of affected projects, we just need to look for the tasks of type Test, and depend on them to make it run:

tasks.register("testsForBranch") {
    val branchProperty = "branch"
    if (!project.hasProperty(branchProperty)) {
        println("please specify branch property with -P$branchProperty=<$branchProperty>")
        return@register
    }
    val branchName = project.property(branchProperty).toString()
    val affectedProjects = projectsAffectedByBranch(branchName, rootProject)
    affectedProjects.forEach {
        val testTasks = it.tasks.withType(Test::class.java).filter {
            it.name in arrayOf("test", "testDebugUnitTest")
        }
        dependsOn(testTasks)
    }
}
Enter fullscreen mode Exit fullscreen mode

In this particular case we are filtering tests tasks just to find those two ones.

To run this task for a branch named test, we would run this command:

./gradlew testsForBranch -Pbranch=test
Enter fullscreen mode Exit fullscreen mode

Happy coding! ✌

💖 💪 🙅 🚩
patxibocos
Patxi Bocos

Posted on July 22, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related