26、构建脚本测试:Task测试(TestKit)、Plugin测试(PluginUnderTest)、构建逻辑单元测试

说实话,写Gradle插件和构建逻辑这件事,很多Android开发都干过。但写完怎么保证它不出幺蛾子?我见过太多人写完插件往项目里一扔,跑一遍没报错就觉得完事了。结果换了个项目环境,或者升级了Gradle版本,直接崩给你看。

嗯,今天我们就来聊聊构建脚本的测试。说白了,就是给你的构建逻辑也写单元测试和集成测试。你想想看,业务代码都要写测试,凭什么构建代码就能免了?

核心观点:构建脚本也是代码,不测试的构建脚本就是定时炸弹。

26.1 三种测试场景,你属于哪一种?

我个人习惯把构建脚本测试分成三类。你在实际项目中遇到的场景,基本都能归到这三类里:

测试类型 测试对象 典型场景
Task测试 单个Task的行为 验证自定义Task的输入输出是否正确
Plugin测试 Plugin的注册和配置逻辑 验证插件是否按预期创建了Task和Extension
构建逻辑单元测试 辅助类、工具函数 测试版本号计算、文件名生成等纯逻辑

我记得刚开始带团队的时候,有个同事写了个插件,里面有个版本号自增的逻辑。结果上线后发现版本号跳过了某个数字,排查了半天,原来是逻辑里有个边界条件没处理。从那以后,我就要求所有构建逻辑必须配套单元测试。

26.2 Task测试:用TestKit跑起来

Gradle提供了GradleRunner,这是TestKit的核心。它能在一个隔离的Gradle环境中执行你的Task,然后断言结果。

为什么要隔离?因为你的Task可能依赖项目里的其他配置。如果直接在开发项目里测,环境干扰太多。TestKit会创建一个临时目录,把测试用的build.gradle写进去,然后跑Gradle命令。

来看一个最简单的例子。假设你有个自定义Task叫PrintInfoTask,它读取一个扩展属性然后打印:

// buildSrc/src/main/java/com/example/PrintInfoTask.java
public class PrintInfoTask extends DefaultTask {
    @Input
    public String message;

    @TaskAction
    public void print() {
        System.out.println("Message: " + message);
    }
}

对应的测试代码长这样:

// src/test/groovy/com/example/PrintInfoTaskTest.groovy
import org.gradle.testkit.runner.GradleRunner
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import spock.lang.Specification

class PrintInfoTaskTest extends Specification {
    @Rule
    TemporaryFolder testProjectDir = new TemporaryFolder()

    File buildFile

    def setup() {
        buildFile = testProjectDir.newFile('build.gradle')
    }

    def "should print message when task executed"() {
        given:
        buildFile << """
            task printInfo(type: com.example.PrintInfoTask) {
                message = 'Hello from test'
            }
        """

        when:
        def result = GradleRunner.create()
            .withProjectDir(testProjectDir.root)
            .withArguments('printInfo')
            .withPluginClasspath()
            .build()

        then:
        result.output.contains('Message: Hello from test')
    }
}

小提示:.withPluginClasspath()会自动把buildSrc或者自定义插件的classpath注入到测试环境中。如果你用的是复合构建,记得加上.withGradleVersion('8.5')指定Gradle版本,避免版本不兼容的问题。

这里有个坑,我曾经踩过——TemporaryFolder在测试结束后会自动删除临时文件。但如果你在调试时想看看生成的build.gradle长什么样,可以在@Rule上加上@TempDir(JUnit 5)或者干脆把keepFiles属性设成true。嗯,不过记得调试完改回来,不然CI上会留下一堆垃圾文件。

26.3 Plugin测试:PluginUnderTest的妙用

测试插件比测试Task稍微复杂一点。因为插件通常要做几件事:注册Extension、创建Task、配置依赖关系。你需要验证这些行为是否都按预期发生了。

Gradle提供了PluginUnderTest这个辅助类,配合GradleRunner一起用。说白了,它帮你自动处理了插件的classpath,你不用手动去配那些烦人的依赖。

举个例子,假设你写了一个插件,它会注册一个greeting扩展和一个sayHello任务:

// buildSrc/src/main/java/com/example/GreetingPlugin.java
public class GreetingPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        def extension = project.extensions.create('greeting', GreetingExtension)
        project.tasks.register('sayHello', SayHelloTask) {
            message = extension.message
        }
    }
}

class GreetingExtension {
    String message = 'Default greeting'
}

abstract class SayHelloTask extends DefaultTask {
    @Input
    abstract Property<String> getMessage()

    @TaskAction
    void sayHello() {
        System.out.println("Hello: " + message.get())
    }
}

测试代码:

// src/test/groovy/com/example/GreetingPluginTest.groovy
import org.gradle.testkit.runner.GradleRunner
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import spock.lang.Specification

class GreetingPluginTest extends Specification {
    @Rule
    TemporaryFolder testProjectDir = new TemporaryFolder()

    File buildFile

    def setup() {
        buildFile = testProjectDir.newFile('build.gradle')
    }

    def "plugin should create sayHello task and use custom message"() {
        given:
        buildFile << """
            plugins {
                id 'com.example.greeting'
            }
            greeting {
                message = 'Custom message'
            }
        """

        when:
        def result = GradleRunner.create()
            .withProjectDir(testProjectDir.root)
            .withArguments('sayHello')
            .withPluginClasspath()
            .build()

        then:
        result.task(':sayHello').outcome == TaskOutcome.SUCCESS
        result.output.contains('Hello: Custom message')
    }
}

注意:如果你在测试中使用了plugins { id '...' }这种DSL,必须确保插件ID已经在META-INF/gradle-plugins里注册了。否则GradleRunner找不到插件,会报Plugin not found的错误。我之前有个项目就是因为忘了写properties文件,排查了半小时。

26.4 构建逻辑单元测试:最容易被忽视的部分

这部分其实最纯粹,也最容易写。说白了,就是把构建脚本里那些辅助函数、工具类拿出来单独测。比如版本号计算、文件名生成、签名配置校验等等。

为什么要单独测?因为这些逻辑往往被嵌在Task或者Plugin里,如果每次都跑完整的Gradle构建来验证,太慢了。你想想看,改了一行版本号计算的代码,要等30秒Gradle初始化才能看到结果,这谁受得了?

来看一个例子。假设你有一个工具类用来计算APK的版本名:

// buildSrc/src/main/java/com/example/VersionCalculator.java
public class VersionCalculator {
    public static String calculateVersionName(int major, int minor, int patch, boolean isRelease) {
        if (isRelease) {
            return String.format("%d.%d.%d", major, minor, patch);
        } else {
            return String.format("%d.%d.%d-SNAPSHOT", major, minor, patch);
        }
    }
}

单元测试:

// src/test/java/com/example/VersionCalculatorTest.java
import org.junit.Test;
import static org.junit.Assert.*;

public class VersionCalculatorTest {
    @Test
    public void releaseVersionShouldNotHaveSnapshotSuffix() {
        String version = VersionCalculator.calculateVersionName(1, 2, 3, true);
        assertEquals("1.2.3", version);
    }

    @Test
    public void snapshotVersionShouldHaveSnapshotSuffix() {
        String version = VersionCalculator.calculateVersionName(1, 2, 3, false);
        assertEquals("1.2.3-SNAPSHOT", version);
    }
}

这种测试跑起来飞快,毫秒级就能出结果。我习惯在每次提交代码前,先跑一遍单元测试,再跑集成测试。如果单元测试挂了,我根本不会去碰集成测试——因为底层逻辑都错了,上层跑再多次也没意义。

26.5 测试金字塔在构建脚本中的应用

嗯,这里我想画一张图,帮你理清这三种测试的关系。说白了,它们构成了一个测试金字塔:

Plugin测试 集成测试,验证插件注册和配置 数量少,执行慢 Task测试 使用TestKit,验证Task行为 数量适中,执行较快 构建逻辑单元测试 测试工具类、辅助函数 数量多,执行最快 覆盖率低 覆盖率高

从这张图你能看出来:

  • 底层是构建逻辑单元测试,数量最多,跑得最快。我建议你覆盖所有工具类和辅助函数。
  • 中间层是Task测试,用TestKit跑。每个核心Task写一个测试用例就够了。
  • 顶层是Plugin测试,数量最少。一般一个插件写一个集成测试,验证整体行为。

我的习惯:在buildSrc里建一个src/test目录,把单元测试和TestKit测试分开。单元测试用JUnit或者Spock都行,TestKit测试我推荐用Spock,因为它的given/when/then结构特别适合描述构建场景。

26.6 避坑指南:我踩过的那些坑

最后分享几个实战中容易翻车的地方:

  1. 不要在生产项目的build.gradle里直接测插件。我曾经这么干过,结果插件里有个bug,把整个项目的编译缓存给清了。从那以后,我所有插件测试都用TestKit隔离环境。
  2. 注意Gradle版本的兼容性。TestKit默认会用当前运行测试的Gradle版本。但你的插件可能要在多个Gradle版本上跑。我建议在CI上配置矩阵测试,分别用Gradle 7.x和8.x跑一遍。
  3. 测试用例要清理临时文件。虽然TemporaryFolder会自动清理,但如果你在测试中手动创建了文件(比如生成报告),记得在cleanup方法里删掉。否则磁盘空间会被慢慢吃光。
  4. 断言要具体。不要只检查result.output.contains('success'),万一输出里其他地方也出现了'success'呢?我习惯用result.task(':xxx').outcome == TaskOutcome.SUCCESS这种精确断言。

嗯,构建脚本测试这块内容其实不难,但很多人就是懒得写。你想想看,一个插件可能被几十个项目引用,如果出了问题,排查成本远高于写测试的成本。所以,别偷懒,该写的测试一个都不能少。


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