使用 Gradle TestKit 测试 Gradle 插件

623 阅读4分钟

正在测试的插件

class MeaningOfLifePlugin : Plugin<Project> {

  override fun apply(project: Project) {
    project.tasks.register(
      "meaningOfLife",
      MeaningOfLifeTask::class.java
    ) { t ->
      t.output.set(
        // in practice, this typically resolves to
        // "build/meaning-of-life.txt"
        project
          .layout
          .buildDirectory
          .file("meaning-of-life.txt")
      )
    }
  }
}

@UntrackedTask(because = "No meaningful inputs")
abstract class MeaningOfLifeTask : DefaultTask() {

  init {
    group = "Gradle All the Way Down"
    description = "Computes the meaning of life"
  }

  @get:OutputFile
  abstract val output: RegularFileProperty

  @TaskAction fun action() {
    // Clean prior output. nb: Gradle ensures this file
    // exists, since it's been annotated with @OutputFile
    val output = output.get().asFile.also { it.delete() }

    // Do a computation
    val meaningOfLife = DeepThought().meaningOfLife()

    // Write output to disk
    output.writeText(meaningOfLife.toString())
  }
}

internal class DeepThought {
  fun meaningOfLife(): Any {
    // ...long-running computation...
    return 42
  }
}

从概念上讲,大多数 Gradle 任务应该属于“纯函数”桶。也就是说,给定一组输入,任务将执行一些“繁重”的计算,然后将一个或多个输出发送到磁盘。我们使用 @UntrackedTask 对其进行注释,这是从 Gradle 7.3 开始提供的注释。这既是文档,也是 Gradle 的标志,它应该跳过输入/输出哈希,这对性能有积极的影响。

测试策略

我们将使用 TestKit 设置一个功能测试套件,该套件将调用真正的 Gradle 构建,然后验证磁盘上的(文件)输出。

构建脚本

plugins {
    // Write Gradle plugins...
    id 'java-gradle-plugin'
    // ...in Java...
    id 'java-library'
    // ...in Kotlin...
    id 'org.jetbrains.kotlin.jvm'
    // ...and test them with Groovy
    id 'groovy'
}

dependencies {
    implementation platform('org.jetbrains.kotlin:kotlin-bom')
    implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
    implementation 'com.google.truth:truth:1.1.3'
    implementation 'com.autonomousapps:testkit-truth:1.1'
    compileOnly 'com.android.tools.build:gradle:7.2.0-beta04'
    compileOnly('org.spockframework:spock-core:2.0-groovy-3.0') {
        exclude group: 'org.codehaus.groovy'
    }
    compileOnly gradleTestKit()
}

// Ensure build/functionalTest doesn't grow without bound when tests sometimes fail to clean up
// after themselves.
def deleteOldFuncTests = tasks.register('deleteOldFuncTests', Delete) {
    delete(layout.buildDirectory.file('functionalTest'))
}

// Configure our test suites (an @Incubating feature)
testing {
    suites {
        // Configure the default test suite
        test {
            // JUnit5 (JUnit Jupiter) is the default
            useJUnitJupiter()
            dependencies {
                // Equivalent to `testImplementation ...` in the
                // top-level dependencies block
                implementation 'com.google.truth:truth:1.1.3'
            }
        }
        functionalTest(JvmTestSuite) {
            useSpock()

            dependencies {
                // functionalTest test suite depends on the production code in tests
                implementation project
                implementation 'com.google.truth:truth:1.1.3'
                implementation 'com.autonomousapps:testkit-truth:1.1'
            }

            targets {
                all {
                    testTask.configure {
                        shouldRunAfter(test)
                        dependsOn(deleteOldFuncTests)

                        maxParallelForks = Runtime.getRuntime().availableProcessors() / 2

                        beforeTest {
                            logger.lifecycle("Running test: $it")
                        }
                    }
                }
            }
        }
    }
}

// Define our plugin
gradlePlugin {
    plugins {
        meaningOfLife {
            id = 'com.zpw.lib.meaning-of-life'
            implementationClass = 'com.zpw.lib.MeaningOfLifePlugin'
        }
    }

    // TestKit needs to know which source set to use. nb: this must come below `testing`, because that
    // is what creates our functionalTest DSL objects.
    testSourceSets(sourceSets.functionalTest)
}

// Minimum Java version of 11
java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(11))
    }
}

// If you run `check`, it will also run our functional tests
tasks.named('check') {
    dependsOn testing.suites.functionalTest
}

// Gradle plugins benefit from this argument
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
    kotlinOptions {
        freeCompilerArgs += '-Xsam-conversions=class'
    }
}

// Groovy code can depend on Kotlin code
def compileFunctionalTestKotlin = tasks.named('compileFunctionalTestKotlin')
tasks.named('compileFunctionalTestGroovy', AbstractCompile) {
    dependsOn compileFunctionalTestKotlin
    classpath += files(compileFunctionalTestKotlin.get().outputs.files)
}

testing DSL 由 JVM Test Suite Plugin 添加,由 java 插件自动添加(由 java-gradle-plugin 插件自动添加)。这个插件及其 DSL 是 @Incubating,这要归功于两个主要功能:(1)它让构建者轻松设置额外的测试套件,(2) 它启用了测试聚合报告插件,它解决了在多项目构建中跨多个子项目聚合测试报告的棘手问题。

我们在 testing 中做了两件事:

配置默认测试套件(命名为“test”,即“单元测试”)以使用 JUnit5(又名 JUnit Jupiter)测试框架,并将 Truth 添加为 testImplementation 依赖项。

添加了一个名为“functionalTest”的新测试套件,并将其配置为使用 Spock 测试框架以及三个依赖项:Truth、TestKit-Truth 和项目本身。默认情况下,新的测试套件在类路径上没有正在测试的项目,这将启用真正的黑盒测试。

有时候在执行task的时候会发现 testing 找不到引用,但是确实存在我们的运行路径中,这个时候需要增加:

compileOnly gradleTestKit()

最后一个块,它设置了从 compileFunctionalTestKotlin 到 compileFunctionalTestGroovy 的依赖关系,他可以让 Groovy 调用 Kotlin,我们将使用 Spock 框架在 Groovy 中编写一个测试。

使用 TestKit 进行功能测试

Spock 是一个强大、富有表现力和非常好的框架。为了清楚起见,我已经包含了 import 语句,因为这个测试(或 Spock 更喜欢的规范)除了 Spock 本身之外还使用了两个外部库,以及一些我们稍后将讨论的自定义接线代码。此代码位于 src/functionalTest/groovy。

PS:Spock可能出现无法引用的情况,这个时候需要增加依赖,让编译通过(其他两个库也一样):

compileOnly('org.spockframework:spock-core:2.0-groovy-3.0') {
    exclude group: 'org.codehaus.groovy'
}
implementation 'com.google.truth:truth:1.1.3'
implementation 'com.autonomousapps:testkit-truth:1.1'
import mutual.aid.fixture.AbstractProject
import mutual.aid.fixture.MeaningOfLifeProject
import mutual.aid.gradle.Builder
import org.gradle.util.GradleVersion
import spock.lang.AutoCleanup
import spock.lang.Specification

import static com.autonomousapps.kit.truth.BuildTaskSubject.buildTasks
import static com.google.common.truth.Truth.assertAbout
import static com.google.common.truth.Truth.assertThat

class MeaningOfLifePluginSpec extends Specification {

  // AbstractProject implements AutoCloseable
  @AutoCleanup
  AbstractProject project

  def "can determine meaning of life (#gradleVersion)"() {
    given: 'A Gradle project'
    project = new MeaningOfLifeProject()

    when: 'We ask for the meaning of life'
    def taskPath = ':meaningOfLife'
    def result = Builder.build(gradleVersion, project, taskPath)

    then: 'Task succeeded'
    assertAbout(buildTasks())
      .that(result.task(taskPath))
      .succeeded()

    and: 'It is 42'
    def actual = project.buildFile('meaning-of-life.txt').text
    assertThat(actual).isEqualTo('42')

    where:
    gradleVersion << [
      GradleVersion.version('7.3.3'), 
      GradleVersion.version('7.4')
    ]
  }
}

上面的代码中嵌入了三种我非常喜欢的模式:

Spock 自己提供的 BDD (given/when/then)。
同样来自 Spock 的数据驱动测试,用于轻松参数化测试。
被测项目(由 AbstractProject 类及其子类封装)和驱动它的规范之间的清晰分离。

首先看看 AbstractProject,它是所有 project 的抽象类,里面没有什么特别的操作,只是定义了所有project的目录结构:

import java.nio.file.Path
import java.util.*
import kotlin.io.path.createDirectories

abstract class AbstractProject: AutoCloseable {
    // 创建 project 根目录
    val projectDir = Path.of("build/functionalTest/${slug()}").createDirectories()
    // 创建 build 编译目录
    private val buildDir = projectDir.resolve("build")

    // 在 build 目录下创建指定目录
    fun buildFile(filename: String): Path {
        return buildDir.resolve(filename)
    }

    // 根据子类创建特殊的文件名
    private fun slug(): String {
        val worker = System.getProperty("org.gradle.test.worker")?.let { w ->
            "-$w"
        }.orEmpty()
        return "${javaClass.simpleName}-${UUID.randomUUID().toString().take(16)}$worker"
    }

    override fun close() {
        projectDir.toFile().deleteRecursively()
    }
}

看看 MeaningOfLifeProject,作为自定义的 Project,它只是在初始化的时候根绝父类的文件路径创建了几个文件,也是我们很熟悉的三个文件:gradle.properties、settings.gradle、build.gradle,有了这三个文件就可以创建一个 gradle 项目了。

import kotlin.io.path.writeText

class MeaningOfLifeProject : AbstractProject() {

  private val gradlePropertiesFile = projectDir.resolve("gradle.properties")
  private val settingsFile = projectDir.resolve("settings.gradle")
  private val buildFile = projectDir.resolve("build.gradle")

  init {
    // Yes, this is independent of our plugin project's properties file
    gradlePropertiesFile.writeText("""
      org.gradle.jvmargs=-Dfile.encoding=UTF-8
    """.trimIndent())

    // Yes, our project under test can use build scans. It's a real project!
    settingsFile.writeText("""
      plugins {
        id 'com.gradle.enterprise' version '3.8.1'
      }
      
      gradleEnterprise {
        buildScan {
          publishAlways()
          termsOfServiceUrl = 'https://gradle.com/terms-of-service'
          termsOfServiceAgree = 'yes'
        }
      }
      
      rootProject.name = 'meaning-of-life'
    """.trimIndent())

    // Apply our plugin
    buildFile.writeText("""
      plugins {
        id 'com.zpw.lib.meaning-of-life'
      }
    """.trimIndent())
  }
}

创建完 project 之后,就可以指定具体任务了:

when: 'We ask for the meaning of life'
def taskPath = ':meaningOfLife'
def result = Builder.build(gradleVersion, project, taskPath)

我们在 groovy 里面调用 kotlin 的话需要特殊的处理,在上面的 gradle 文件中已经处理过了。

有必要详细讨论这些断言。让我们更仔细地看一下:

then: 'Task succeeded'
assertAbout(buildTasks()).that(result.task(taskPath)).succeeded()

and: 'It is 42'
def actual = project.buildFile('meaning-of-life.txt').text
assertThat(actual).isEqualTo('42')

第一部分简单地断言给定的任务 (":meaningOfLife") 是成功的。断言本身是由 Truth 驱动的,并且我编写了一个名为 testkit-truth 的自定义扩展,它让我们可以编写关于 Gradle 构建结果的流畅断言。

我们的插件会生成一个带有路径 build/meaning-of-life.txt 的文件输出。因此,我们运行一个构建,然后直接读取该构建生成的文件,并且断言具有预期值。这个策略有两点非常强大:(1)这些不是单元测试,所以它们与实现完全无关。我们可以重写进行昂贵的“meaning of life”计算的代码,这些测试仍然可以防止回归。这使得开发测试基础设施所花费的时间成为一项很好的长期投资:没有或很少有流失。(2) 如果我们的测试套件有任何问题,一种非常有用的调试策略是直接检查生成的代码!这些文件确实存在于磁盘上,如果您在 IDE 中导航到它们,它将提供语法突出显示。

Fixtures, Kotlin-style

所有导入都指向外部库或用 Kotlin 编写的自定义测试装置,它们位于src/functionalTest/kotlin。

首先,我们有一个围绕 GradleRunner 的包装器,我在所有项目中都包含了它的版本。如果使用适当的参数调用 build(),最终效果将是在测试中执行真正的 Gradle 构建。 Gradle 调用 Gradle,结果返回为 BuildResult 用于断言。

object Builder {
  @JvmOverloads
  @JvmStatic
  fun build(
    gradleVersion: GradleVersion = GradleVersion.current(),
    project: AbstractProject,
    vararg args: String
  ): BuildResult = runner(
    gradleVersion,
    project.projectDir,
    *args
  ).build()

  private fun runner(
    gradleVersion: GradleVersion,
    projectDir: Path,
    vararg args: String
  ): GradleRunner = GradleRunner.create().apply {
    forwardOutput()
    withPluginClasspath()
    withGradleVersion(gradleVersion.version)
    withProjectDir(projectDir.toFile())
  }
}

为了更好地理解这个夹具的作用,我认为检查我们运行规范时生成的文件是最简单的。首先,稍作改动并运行规范:

image.png

你会注意到我们已经注释掉了@AutoCleanup,以阻止Spock 运行我们的fixture 的close() 方法。单击运行按钮,然后在 IDE 的项目视图中展开构建目录:

image.png

你会注意到我们有两组基本相同的生成文件,只是根目录的名称不同,这当然是 UUID + Gradle 从上面的 slug() 函数提供的系统属性的组合。我们现在可以打开其中一个 build.gradle 文件,看到它正是我们在 MeaningOfLifeProject 中定义的

image.png

直观地检查生成的文件与调试相比会快得多。我们还可以看到运行我们的规范的结果:

image.png

以及其中一项测试的控制台输出:

> Task :meaningOfLife

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed

Publishing build scan...
https://gradle.com/s/s7rw4ig672sa6