正在测试的插件
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())
}
}
为了更好地理解这个夹具的作用,我认为检查我们运行规范时生成的文件是最简单的。首先,稍作改动并运行规范:
你会注意到我们已经注释掉了@AutoCleanup,以阻止Spock 运行我们的fixture 的close() 方法。单击运行按钮,然后在 IDE 的项目视图中展开构建目录:
你会注意到我们有两组基本相同的生成文件,只是根目录的名称不同,这当然是 UUID + Gradle 从上面的 slug() 函数提供的系统属性的组合。我们现在可以打开其中一个 build.gradle 文件,看到它正是我们在 MeaningOfLifeProject 中定义的
直观地检查生成的文件与调试相比会快得多。我们还可以看到运行我们的规范的结果:
以及其中一项测试的控制台输出:
> Task :meaningOfLife
BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
Publishing build scan...
https://gradle.com/s/s7rw4ig672sa6