Defensive development: Gradle plugin development for busy engineers
Tags: gradle, android, kotlin
Published: 2022-03-16 21:08:33

This post is a direct follow-up to [Gradle all the way down: Testing your Gradle plugin with Gradle TestKit](https://dev.to/autonomousapps/gradle-all-the-way-down-testing-your-gradle-plugin-with-gradle-testkit-2hmc). You don't have to read that, but I will make no effort to explain anything here that was already explained there so, you know. You were warned.

## Let's assume you're busy

Since the whole premise of this post is that we're all too busy for _class loader shenanigans_ (except me, since I'm paid to think about them, woohoo), let's cut to the chase and see the **tl;dr**:

> If your plugin depends on the Android Gradle Plugin (AGP) (or any third-party [ecosystem plugin](https://github.com/autonomousapps/gradle-glossary#ecosystem-plugin)?), you should _strongly consider_ declaring it as `compileOnly`.

This is my advice for the following very simple reason:

> You cannot know what your users will do, so you should assume they will do anything and everything.

<a name="source-1" />Or, perhaps less opaquely:

> For a variety of reasons outside of the scope of this post, AGP is _critical infrastructure_ for a build. It is not a normal dependency that should be allowed to go through normal dependency resolution. Imagine if a plugin could silently change the version of Gradle your build ran against. It's (almost) that bad! [<sup>1</sup>](#endnotes)

While I think the above explanation is sufficient justification for the `compileOnly` advice, I can also justify it with practical concerns. The remainder of this post is a series of explorations with this idea in mind.

All of the code below (and more!) can be found on [Github](https://github.com/drop-ice/dear-github-2.0).

{% embed https://github.com/autonomousapps/blog-gradle-all-the-way-down %}

## A test harness

Talk is cheap, but assertions are worth their weight in gold. As you may recall from the first post in this series, [Spock](https://spockframework.org/spock/docs/2.0/all_in_one.html) is my favorite JVM testing framework. Here's the first iteration of a spec for exploring our problem space:

```groovy
class AndroidSpec extends Specification {

  @AutoCleanup
  AbstractProject project

  def "gets the expected version of AGP on the classpath (#gradleVersion AGP #agpVersion)"() {
    given: 'An Android Gradle project'
    project = new AndroidProject(agpVersion)

    when: 'We check the version of AGP on the classpath'
    def result = Builder.build(
      gradleVersion,
      project, 
      'lib:which', '-e', 'android'
    )

    // The output will contain a line like this:
    // jar for 'android': file:/path/to/gradle-all-the-way-down/plugin/build/tmp/functionalTest/work/.gradle-test-kit/caches/jars-9/f19c6db5e8f27caa4113e88608762369/gradle-4.2.2.jar
    then: 'It matches what the project provides, not what the plugin compiles against'
    def androidJar = result.output.split('\n').find {
      it.startsWith("jar for 'android'")
    }
    assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")

    where:
    [gradleVersion, agpVersion] << gradleAgpCombinations()
  }
}
```

Before I take this apart for you, let's start with understanding the flow at a high level:
1. We create an Android project for testing against.
2. We run a task named `which` with the option `-e android`.
3. We **assert** that the jar we find via step 2 has the correct version information.
4. We run this whole thing against a matrix of Gradle and AGP versions, because we're thorough.

Here's what that spec looks like when run from the IDE:

![Screenshot of spec run from the IDE, showing all four AGP x Gradle combinations](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/71f4dnbkrwtkcoykumzh.png)

or from CLI:
```
$ ./gradlew plugin:functionalTest --tests AndroidSpec
> Task :plugin:functionalTest
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 7.1.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 7.1.2)(mutual.aid.AndroidSpec)
```

So that's the power of Spock. It's really easy to generate data pipelines for parameterized tests. If you're not actively afraid of this capability by the end of this post, you're not reading closely enough.

### Data-driven testing

Since the data-driven aspect is so crucial to understanding the concepts we're exploring, I want to show its implementation:

```groovy
final class Combinations {
  static List<List> gradleAgpCombinations(
    List<Object>... others = []
  ) {
    return [
      gradleVersions(), agpVersions(), *others
    ].combinations()
  }

  static List<GradleVersion> gradleVersions() {
    return [
      GradleVersion.version('7.3.3'),
      GradleVersion.version('7.4.1')
    ]
  }

  static List<String> agpVersions() {
    return ['4.2.2', '7.1.2']
  }
}
```

I know I promised in my last post that there wouldn't be any more Groovy, but the `combinations()` function is simply too good to ignore. Not only do I not want to have to implement it myself, I don't see why I would want to import another library to do it when the Groovy GDK is already available (which it always will be with Gradle projects).

### Which AGP?

To programmatically inspect which version of AGP is on the *runtime classpath* of our build, I wrote a little helper script that registers a task, `which`, that our spec invokes. Here's how that is defined:

```groovy
// which.gradle
tasks.register('which', WhichTask)

@UntrackedTask(because = 'Not worth tracking')
abstract class WhichTask extends DefaultTask {

  WhichTask() {
    group = 'Help'
    description = 'Print path to jar providing extension, or list of all available extensions and their types'
  }

  @Optional
  @Option(option = 'e', description = 'Which extension?')
  @Input
  abstract String ext

  @TaskAction def action() {
    if (ext) printLocation()
    else printExtensions()
  }

  private void printLocation() {
    def jar = project.extensions.findByName(ext)
      ?.class
      ?.protectionDomain
      ?.codeSource
      ?.location

    if (jar) {
      logger.quiet("jar for '$ext': $jar")
    } else {
      logger.quiet("No extension named '$ext' registered on project.")
    }
  }

  private void printExtensions() {
    logger.quiet('Available extensions:')
    project.extensions.extensionsSchema.elements.sort { it.name }.each {
      // fullyQualifiedName since Gradle 7.4
      logger.quiet("* ${it.name}, ${it.publicType.fullyQualifiedName}")
    }
  }
}
```

This task can be run in two modes:
1. `./gradlew which -e <some-extension>` or
1. `./gradlew which`

The first will print the path to the jar that provides the extension (such as `"android"`), while the second prints all of the extensions available for the given module, along with their fully-qualified types (this can be useful if you're trying to discover the types of extensions). I've been using this trick in the debugger for a long time.

## Test scenarios

Let's elaborate. First, I added a flag to my plugin's build script to let me change how it is built, so that I could explore this behavior with automated tests. This is nothing something I would ever recommend in the general case&mdash;it's just for the assertions that drive the following exploratory scenarios.

```groovy
// plugin/build.gradle
// -Dimpl (for 'implementation')
boolean impl = providers.systemProperty('impl').orNull != null

dependencies {
  if (impl) {
    implementation 'com.android.tools.build:gradle:7.2.0-beta04'
  } else {
    compileOnly 'com.android.tools.build:gradle:7.2.0-beta04'
  }
}
```

By default, we use `compileOnly`, but if you pass `-Dimpl` during a build, we'll use `implementation` instead. We must also update our test configuration, because we need that flag available in the test JVM (which is _forked_ from the main JVM and doesn't get all of its system properties by default).

```groovy
// plugin/build.gradle
testTask.configure {
  ...
  systemProperty('impl', impl)
  ...
}
```

With that small change, we can now elaborate on our spec.

### Iteration 1: does it matter if the user declares AGP in the root `buildscript` block?

```groovy
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(agpVersion,useBuildScript)

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'") 
  }
  def expected = useBuildScript
    ? "gradle-${agpVersion}.jar"
    : 'gradle-7.2.0-beta04.jar'
  assertThat(androidJar).endsWith(expected)

  where: '2^3=8 combinations'
  [gradleVersion, agpVersion, useBuildScript] << gradleAgpCombinations([true, false])
}
```

In real terms, those scenarios map to these two Gradle build scripts:
```groovy
// settings.gradle -- for BOTH versions of the build script
pluginManagement {
  repositories {
    gradlePluginPortal()
    google()
    mavenCentral()
  }
  plugins {
    // Centralized version declarations. These do not directly 
    // impact the classpath. Rather, this simply lets you have
    // a single place to declare all plugin versions.
    id 'com.android.library' version '7.1.2'
  }
}

// build.gradle 1
// useBuildScript = false
plugins {
  id 'com.android.library' apply false
}

// build.gradle 2
// useBuildScript = true
buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}
```

![Screencap of the test results, showing there are 8 individual tests generated from this one spec](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ah1ml8rgi29q02bhs5iv.png)

From the fact that our spec passed, we can confidently answer the question in the heading: yes, the result depends on whether the user declares AGP in the root project's `buildscript` block. For users unfamiliar with Android, please be aware this has been standard practice since time immemorial, and has only started changing with the latest template.

And of course this is already problematic: we have created a scenario where a plugin has managed to upgrade our build to a beta version of AGP.

### Iteration 2: What happens if we turn off some TestKit magic?

There's a bit of magic in TestKit that puts your plugin-under-test on the build classpath so that you don't have to. It's very useful for simple scenarios, but I find it lacking for industrial-scale use-cases. To test these scenarios, first we must make an update to our build script:

```groovy
// plugin/build.gradle
plugins {
  ...
  id 'maven-publish'
}

group = 'mutual.aid'
version = '1.0'

// Some specs rely on the plugin as an external artifact
// This task is added to the build by the maven-publish plugin
def publishToMavenLocal = tasks.named('publishToMavenLocal')

testTask.configure {
  ...
  dependsOn(publishToMavenLocal)
  ...
}
```

Now, whenever we run the `functionalTest` task, it will first publish our plugin to maven local (`~/.m2/repositories`).

Now make this change to `Builder` to let us vary this behavior:

```kotlin
private fun runner(
  gradleVersion: GradleVersion,
  projectDir: Path,
  withPluginClasspath: Boolean,
  vararg args: String
): GradleRunner = GradleRunner.create().apply {
  ...
  if (withPluginClasspath) {
    withPluginClasspath()
  }
  ...
}
```

And finally, our updated spec:

```groovy
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScript,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    // !useMavenLocal => withPluginClasspath
    !useMavenLocal,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  def expected
  if (useBuildScript || useMavenLocal) {
    expected = "gradle-${agpVersion}.jar"
  } else {
    expected = 'gradle-7.2.0-beta04.jar'
  }

  // Our assertion is growing more complicated
  assertThat(androidJar).endsWith(expected)

  where: '2^4=16 combinations'
  [gradleVersion, agpVersion, useBuildScript, useMavenLocal] << gradleAgpCombinations(
    // useBuildScript
    [true, false],
    // useMavenLocal
    [true, false],
  )
}
```

In real terms, those scenarios map to these four Gradle build scripts:
```groovy
// settings.gradle now varies
pluginManagement {
  repositories {
    if (useMavenLocal) mavenLocal()
    gradlePluginPortal()
    google()
    mavenCentral()
  }
}

// build.gradle 1
// useBuildScript = true
// useMavenLocal = false
buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}

// build.gradle 2
// useBuildScript = true
// useMavenLocal = true
buildscript {
  repositories {
    mavenLocal()
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}

// build.gradle 3
// useBuildScript = false
// useMavenLocal = true
plugins {
  id 'com.android.library' apply false
}

// build.gradle 4 (identical to 3, but recall settings.gradle
// varies)
// useBuildScript = false
// useMavenLocal = false
plugins {
  id 'com.android.library' apply false
}
```

![Screencap of the test results, showing there are 16 individual tests generated from this one spec](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/az9oilws90zyssrpy0a7.png)
 
Since our spec has passed, we know that yes, TestKit classpath magic influences the results of our build. Since TestKit is not in play, ever, in real builds, I prefer to not use the `withPluginClasspath()` method, and instead always rely on publishing my plugin to maven local, as it more closely mimics real builds.

### Iteration 3: Does it matter if we use `buildscript` for our plugin-under-test?



```groovy
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScriptForAgp,
    useBuildScriptForPlugin,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    !useMavenLocal,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  def expected
  if (useBuildScriptForAgp && useBuildScriptForPlugin && useMavenLocal) {
    // our 'implementation' dependency has greater priority
    expected = 'gradle-7.2.0-beta04.jar'
  } else if (useBuildScriptForAgp || useMavenLocal) {
    // the project's requirements have greater priority
    expected = "gradle-${agpVersion}.jar"
  } else {
    // our 'implementation' dependency has greater priority
    expected = 'gradle-7.2.0-beta04.jar'
  }

  // Note that the assertion is... complicated.
  assertThat(androidJar).endsWith(expected)

  where: 'There is a truly atrocious combinatorial explosion'
  [gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
    gradleAgpCombinations(
      // useBuildScriptForAgp
      [true, false],
      // useBuildScriptForPlugin
      [true, false],
      // useMavenLocal
      [true, false],
    )
}
```

This time, rather than share all of the individual variations, I'll leave the if/else logic in place so you can use your imagination. Note that the following incorporates some pseudocode for improved readability.

```groovy
// settings.gradle
pluginManagement {
  repositories {
    if (useMavenLocal) mavenLocal()
    gradlePluginPortal()
    google()
    mavenCentral()
  }
  plugins {
    id 'com.android.library' version '7.1.2'
    id 'mutual.aid.meaning-of-life' version '1.0'
  }
}

// build.gradle
if (useBuildScriptForAgp) {
  buildscript {
    repositories {
      if (useMavenLocal) mavenLocal()
      google()
      mavenCentral()
    }
    dependencies {
      classpath 'com.android.tools.build:gradle:4.2.2'
      if (useBuildScriptForPlugin) classpath 
  'mutual.aid.meaning-of-life:mutual.aid.meaning-of-life.gradle.plugin:1.0' 
    }
  }
} else {
  plugins {
    id 'com.android.library' apply false
  }
}
```

Aaaand I'm starting to run out of screen real estate for this spec.

![Screencap of the test results, showing there are 32 individual tests generated from this one spec](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/is0b2dtyesew3gl66ciy.png)
 
<a name="source-2" />We now have a combinatorial explosion :tada: The version of AGP you'll end up with in a real build is now very hard to predict, unless you're an expert and understand how the class loader relationships work,[<sup>2</sup>](#endnotes) and how those relationships interact (or don't interact) with the dependency resolution engine.

### Iteration 4: We can (and must) do better! Or, the null hypothesis.

Let's consider the following spec. At first glance, it might seem complicated, but note that the assertion is _always_ the same. That is, no matter what combination of flags we pass (i.e., no matter what our user might decide to do), we _always_ end up with the version of AGP the _user_ specifies. This is the power of `compileOnly`.

```groovy
@IgnoreIf({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for compileOnly (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScriptForAgp,
    useBuildScriptForPlugin,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(gradleVersion, project, 'lib:which', '-e', 'android')

  then: 'It matches what the project provides, not the plugin, always'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  // Note that the assertion is always the same
  assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")

  where:
  [gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
    gradleAgpCombinations(
      // useBuildScriptForAgp
      [true, false],
      // useBuildScriptForPlugin
      [true, false],
      // useMavenLocal
      [true, false],
    )
}
```

![Screencap of the test results, showing that all 32 combinations of our improved spec pass](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/61xvfmv3rtzqap7opq7q.png)
 
Q.E.D. &#8718;

<a name="source-3" />
## Two roads diverged in a wood[<sup>3</sup>](#endnotes)

There you have it. Unless it's your day job to debug class loader and dependency resolution interactions, I would highly recommend you keep it simple, take the well-traveled road, and use `compileOnly`. No matter what, you'll get the correct result.

## Bonus content

Since I don't like misleading people, I have to acknowledge that, even if _you_ do the right thing and use `compileOnly`, some plugin you depend on might still use `implementation` and bring AGP onto your runtime classpath, whether you want it there or not. Early on in the process of [migrating my large build to convention plugins](https://developer.squareup.com/blog/herding-elephants/), I ran into just this issue, and found myself in a scenario where _two_ versions of AGP ended up available at runtime! :scream: To prevent that happening in the future, I added the following task to all my convention plugin projects, and run it in CI.


Here's the task implementation:

```kotlin
@UntrackedTask(because = "Not worth tracking")
abstract class NoAgpAtRuntime : DefaultTask() {

  @get:Internal
  lateinit var artifacts: ArtifactCollection

  @PathSensitive(PathSensitivity.RELATIVE)
  @InputFiles
  fun getResolvedArtifactResult(): FileCollection {
    return artifacts.artifactFiles
  }

  @TaskAction fun action() {
    val android = artifacts.artifacts
      .map { it.id.componentIdentifier.displayName }
      .filter { it.startsWith("com.android.tools") }
      .toSortedSet()

    if (android.isNotEmpty()) {
      val msg = buildString {
        appendLine("AGP must not be on the runtime classpath. The following AGP libs were discovered:")
        android.forEach { a ->
          appendLine("- $a")
        }
        appendLine(
          "The most likely culprit is `implementation 'com.android.tools.build:gradle'` in your dependencies block"
        )
      }
      throw GradleException(msg)
    }
  }
}
```

And here's how it's registered:

```groovy
// register the task
def runtimeClasspath = project.configurations.findByName('runtimeClasspath')
if (runtimeClasspath) {
  def noAgpAtRuntime = tasks.register('noAgpAtRuntime', NoAgpAtRuntime) {
    artifacts = runtimeClasspath.incoming.artifacts
  }

  tasks.named('check').configure {
    dependsOn noAgpAtRuntime
  }
}
```

The solution, when this check fails, is to find the third-party plugin that's using `implementation` for AGP, and declare it as `compileOnly` as well.

<a name="endnotes"/>
## Endnotes
<sup>1</sup> Gradle doesn't provide a way to model this. [<small>up</small>](#source-1)
<sup>2</sup> It's always deterministic, but challenging for the non-expert to predict. [<small>up</small>](#source-2)
<sup>3</sup> [The Road Not Taken](https://www.poetryfoundation.org/poems/44272/the-road-not-taken), by Robert Frost. [<small>up</small>](#source-3)