17、插件测试:PluginUnderTestSpecification、TestKit、功能测试

写插件这件事,说实话,最怕的就是「本地跑得好,一上 CI 就炸」。我早期吃过这个亏,写了个自定义任务插件,本地测了三遍都没问题,结果同事一用就报 ClassNotFoundException。后来我才意识到——插件测试,不是可选项,是必修课。

Gradle 官方给了我们一套完整的测试方案,核心就是 PluginUnderTestSpecificationTestKit。今天咱们就把这套东西掰开揉碎,看看怎么写出靠谱的插件测试。

为什么插件测试这么特殊?

普通应用测试,你 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()
}

然后写测试代码。核心类就两个:GradleRunnerBuildResult

第一个测试:跑一个简单的构建

咱们先写个最基础的,验证插件能不能成功应用:

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,得用 @RuleTemporaryFolder。我个人推荐统一用 JUnit 5,省得混用。

SVG:插件测试知识体系

插件测试知识体系 Gradle 插件测试 TestKit 核心 GradleRunner BuildResult withPluginClasspath() 测试类型 单元测试 功能测试 多版本测试 验证要点 Task 输出/缓存 Extension 解析 错误信息提示

常见陷阱与避坑指南

陷阱一:测试跑得慢

每个测试用例都会启动一个 Gradle 守护进程,如果测试多了,速度会非常慢。我曾经一个插件有 30 个测试用例,跑一次要 8 分钟。后来我把测试分成了两组:一组是「快速验证」(只测加载和基本功能),另一组是「完整测试」(测所有场景)。CI 上跑完整测试,本地开发只跑快速验证。

技巧:复用 GradleRunner 实例

如果多个测试用例使用相同的 Gradle 版本和插件 classpath,可以复用 GradleRunner 实例。但要注意,withProjectDir 每次都要重新设置,因为不同测试的临时目录不同。

陷阱二:测试依赖外部资源

如果你的插件需要下载依赖(比如 Maven 仓库里的库),测试时可能会因为网络问题失败。我建议在测试里用 withPluginClasspath() 而不是 withClasspath(),前者只加载本地 classpath,不涉及远程仓库。如果必须测试远程依赖,可以搭一个本地 Maven 仓库或者用 --offline 模式。

总结

插件测试说白了就是「用 Gradle 来测 Gradle」。TestKit 给了我们一把好用的锤子,但钉子怎么钉,还得看你的场景。

我个人习惯是:每个插件至少有一个「冒烟测试」(验证能加载),一个「功能测试」(验证核心逻辑),一个「失败测试」(验证错误处理)。这三个覆盖了 90% 的问题。剩下的 10%,等你上线后用户来帮你发现——嗯,开个玩笑,但确实有些边界情况只有真实项目才能触发。

最后提醒一句:测试代码也是代码,别写得太随意。我见过有人把测试脚本写成一坨字符串拼接,后来改插件配置时,测试脚本也得跟着改,痛苦得很。保持测试代码的可读性,跟生产代码一样重要。


公众号:蓝海资料掘金营,微信deep3321