17、插件测试:PluginUnderTestSpecification、TestKit、功能测试
写插件这件事,说实话,最怕的就是「本地跑得好,一上 CI 就炸」。我早期吃过这个亏,写了个自定义任务插件,本地测了三遍都没问题,结果同事一用就报 ClassNotFoundException。后来我才意识到——插件测试,不是可选项,是必修课。
Gradle 官方给了我们一套完整的测试方案,核心就是 PluginUnderTestSpecification 和 TestKit。今天咱们就把这套东西掰开揉碎,看看怎么写出靠谱的插件测试。
为什么插件测试这么特殊?
普通应用测试,你 mock 一下接口、assert 一下返回值就完事了。但插件不一样——它运行在 Gradle 的构建生命周期里,依赖 Project 对象、Task 图、配置阶段等等。说白了,你的插件是在一个「框架内部」工作的,不启动这个框架,你根本测不到真实行为。
我见过有人用 JUnit 直接 new 一个 Project 对象来测,结果一堆 Gradle 内部 API 报错。嗯,这条路走不通。
TestKit:Gradle 官方测试框架
Gradle TestKit 就是专门干这个的。它允许你在测试中启动一个「迷你 Gradle 构建」,然后运行你的插件,最后检查构建结果。整个过程是真实的 Gradle 运行时,不是 mock。
用法很简单,先加依赖:
// build.gradle.kts
dependencies {
testImplementation(gradleTestKit())
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
tasks.test {
useJUnitPlatform()
}
然后写测试代码。核心类就两个:GradleRunner 和 BuildResult。
第一个测试:跑一个简单的构建
咱们先写个最基础的,验证插件能不能成功应用:
class MyPluginTest {
@Test
fun `plugin should apply without error`() {
val projectDir = File("build/test-project")
projectDir.mkdirs()
// 写一个简单的 build.gradle.kts
File(projectDir, "build.gradle.kts").writeText("""
plugins {
id("com.example.my-plugin")
}
""".trimIndent())
// 用 TestKit 运行
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("tasks")
.build()
// 检查结果
assertThat(result.task(":tasks")).isNotNull()
}
}
这里有个关键点:withPluginClasspath()。它会自动把当前项目的 classpath 注入到测试构建中,你的插件类就能被 Gradle 找到了。我第一次用的时候忘了加这行,结果一直报「Plugin not found」,折腾了半小时。
PluginUnderTestSpecification:更优雅的写法
每次手动创建目录、写 build.gradle.kts 文件,太啰嗦了。Gradle 提供了 PluginUnderTestSpecification 这个基类,封装了这些重复操作。
不过注意,这个类在 gradleTestKit() 的依赖里,但需要你手动 import。用法如下:
import org.gradle.testkit.runner.PluginUnderTestSpecification
class MyPluginTest : PluginUnderTestSpecification() {
@Test
fun `plugin adds hello task`() {
// 准备一个简单的构建脚本
withProject("""
plugins {
id("com.example.my-plugin")
}
""".trimIndent())
// 运行任务
val result = run("hello")
// 验证输出
assertThat(result.output).contains("Hello from plugin!")
}
}
我个人习惯用这个基类,因为它帮我处理了临时目录的清理、classpath 的注入,省心不少。但要注意,它底层还是用的 GradleRunner,所以性能上没有差别。
功能测试:验证插件的实际行为
单元测试只能验证「插件能不能加载」,但真正重要的是「插件干了什么」。这就需要功能测试了。
举个例子,我写了一个代码生成插件,它会在 build 目录下生成一个 generated/Source.java 文件。功能测试应该这样写:
@Test
fun `plugin generates source file`() {
val projectDir = tempFolder.root
// 构建脚本
File(projectDir, "build.gradle.kts").writeText("""
plugins {
id("com.example.codegen")
}
codegen {
outputPackage = "com.example.generated"
}
""".trimIndent())
// 运行 generate 任务
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("generate")
.build()
// 检查生成的文件
val generatedFile = File(projectDir, "build/generated/Source.java")
assertThat(generatedFile).exists()
assertThat(generatedFile.readText()).contains("package com.example.generated")
}
这里我踩过一个坑:withArguments("generate") 默认不会执行依赖任务。如果你的 generate 任务依赖了 compileJava,你得显式加上 --rerun-tasks 或者直接跑 build。否则可能生成文件还没创建,测试就断言失败了。
测试不同 Gradle 版本
插件要兼容多个 Gradle 版本,这是常见需求。TestKit 支持指定 Gradle 版本:
@Test
fun `works with Gradle 7.6`() {
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withGradleVersion("7.6")
.withArguments("build")
.build()
assertThat(result.task(":build")?.outcome).isEqualTo(TaskOutcome.SUCCESS)
}
@Test
fun `works with Gradle 8.5`() {
val result = GradleRunner.create()
.withGradleVersion("8.5")
// ... 同上
.build()
}
我曾经维护过一个插件,用户反馈在 Gradle 6.x 上报错。我本地用的是 8.x,一直没复现。后来加上多版本测试,才发现是某个 API 在 6.x 里是 @Incubating,行为不一样。从那以后,我的每个插件都至少测三个版本:最低支持版、当前稳定版、最新版。
测试失败场景
好的测试不光测成功路径,还要测失败路径。比如插件配置错误时,应该给出清晰的错误信息:
@Test
fun `should fail when outputDir is not set`() {
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("generate")
.buildAndFail() // 注意是 buildAndFail,不是 build
assertThat(result.output).contains("outputDir must be configured")
}
buildAndFail() 这个方法很关键。它期待构建失败,如果构建成功了反而会抛异常。我刚开始总用 build() 然后 try-catch,后来发现官方早就替我想好了。
测试 Task 的输入输出缓存
Gradle 的增量构建特性依赖于 Task 的输入输出注解。如果插件里的 Task 没有正确声明 @Input 和 @OutputDirectory,缓存就会失效。测试这个也很简单:
@Test
fun `task is UP-TO-DATE when inputs unchanged`() {
// 第一次运行
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("generate")
.build()
// 第二次运行,不修改任何输入
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("generate")
.build()
assertThat(result.task(":generate")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE)
}
如果第二次运行不是 UP_TO_DATE,说明你的 Task 输入声明有问题。我遇到过最坑的情况是:Task 里用了一个 System.currentTimeMillis() 作为内部逻辑,结果每次运行都认为输入变了。后来改成用 @Input 注解的配置属性才解决。
测试插件扩展(Extension)
很多插件会提供 DSL 扩展,比如 myPlugin { name = "xxx" }。测试扩展的解析逻辑也很重要:
@Test
fun `extension values are parsed correctly`() {
File(projectDir, "build.gradle.kts").writeText("""
plugins {
id("com.example.my-plugin")
}
myPlugin {
name = "test-name"
version = "1.0"
}
""".trimIndent())
val result = GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments("printConfig")
.build()
assertThat(result.output).contains("name: test-name")
assertThat(result.output).contains("version: 1.0")
}
这里有个小技巧:在插件里加一个「调试任务」,专门打印扩展的配置值。测试时跑这个任务,然后 assert 输出。这样就不用反射去拿扩展对象了,简单粗暴。
测试中的临时目录管理
每次测试都创建临时目录,跑完要清理。JUnit 5 提供了 @TempDir 注解,但 TestKit 的 GradleRunner 需要你手动管理。我一般这样写:
class MyPluginTest {
@TempDir
lateinit var testProjectDir: File
@BeforeEach
fun setup() {
// 每个测试前,在临时目录里创建 build.gradle.kts
File(testProjectDir, "build.gradle.kts").writeText("""
plugins {
id("com.example.my-plugin")
}
""".trimIndent())
}
@Test
fun `test something`() {
val result = GradleRunner.create()
.withProjectDir(testProjectDir)
.withPluginClasspath()
.withArguments("tasks")
.build()
// ...
}
}
注意 @TempDir 是 JUnit 5 的,如果你用 JUnit 4,得用 @Rule 的 TemporaryFolder。我个人推荐统一用 JUnit 5,省得混用。
SVG:插件测试知识体系
常见陷阱与避坑指南
陷阱一:测试跑得慢
每个测试用例都会启动一个 Gradle 守护进程,如果测试多了,速度会非常慢。我曾经一个插件有 30 个测试用例,跑一次要 8 分钟。后来我把测试分成了两组:一组是「快速验证」(只测加载和基本功能),另一组是「完整测试」(测所有场景)。CI 上跑完整测试,本地开发只跑快速验证。
技巧:复用 GradleRunner 实例
如果多个测试用例使用相同的 Gradle 版本和插件 classpath,可以复用 GradleRunner 实例。但要注意,withProjectDir 每次都要重新设置,因为不同测试的临时目录不同。
陷阱二:测试依赖外部资源
如果你的插件需要下载依赖(比如 Maven 仓库里的库),测试时可能会因为网络问题失败。我建议在测试里用 withPluginClasspath() 而不是 withClasspath(),前者只加载本地 classpath,不涉及远程仓库。如果必须测试远程依赖,可以搭一个本地 Maven 仓库或者用 --offline 模式。
总结
插件测试说白了就是「用 Gradle 来测 Gradle」。TestKit 给了我们一把好用的锤子,但钉子怎么钉,还得看你的场景。
我个人习惯是:每个插件至少有一个「冒烟测试」(验证能加载),一个「功能测试」(验证核心逻辑),一个「失败测试」(验证错误处理)。这三个覆盖了 90% 的问题。剩下的 10%,等你上线后用户来帮你发现——嗯,开个玩笑,但确实有些边界情况只有真实项目才能触发。
最后提醒一句:测试代码也是代码,别写得太随意。我见过有人把测试脚本写成一坨字符串拼接,后来改插件配置时,测试脚本也得跟着改,痛苦得很。保持测试代码的可读性,跟生产代码一样重要。
公众号:蓝海资料掘金营,微信deep3321