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 测试金字塔在构建脚本中的应用
嗯,这里我想画一张图,帮你理清这三种测试的关系。说白了,它们构成了一个测试金字塔:
从这张图你能看出来:
- 底层是构建逻辑单元测试,数量最多,跑得最快。我建议你覆盖所有工具类和辅助函数。
- 中间层是Task测试,用TestKit跑。每个核心Task写一个测试用例就够了。
- 顶层是Plugin测试,数量最少。一般一个插件写一个集成测试,验证整体行为。
我的习惯:在buildSrc里建一个src/test目录,把单元测试和TestKit测试分开。单元测试用JUnit或者Spock都行,TestKit测试我推荐用Spock,因为它的given/when/then结构特别适合描述构建场景。
26.6 避坑指南:我踩过的那些坑
最后分享几个实战中容易翻车的地方:
- 不要在生产项目的build.gradle里直接测插件。我曾经这么干过,结果插件里有个bug,把整个项目的编译缓存给清了。从那以后,我所有插件测试都用TestKit隔离环境。
- 注意Gradle版本的兼容性。TestKit默认会用当前运行测试的Gradle版本。但你的插件可能要在多个Gradle版本上跑。我建议在CI上配置矩阵测试,分别用Gradle 7.x和8.x跑一遍。
- 测试用例要清理临时文件。虽然
TemporaryFolder会自动清理,但如果你在测试中手动创建了文件(比如生成报告),记得在cleanup方法里删掉。否则磁盘空间会被慢慢吃光。 - 断言要具体。不要只检查
result.output.contains('success'),万一输出里其他地方也出现了'success'呢?我习惯用result.task(':xxx').outcome == TaskOutcome.SUCCESS这种精确断言。
嗯,构建脚本测试这块内容其实不难,但很多人就是懒得写。你想想看,一个插件可能被几十个项目引用,如果出了问题,排查成本远高于写测试的成本。所以,别偷懒,该写的测试一个都不能少。