构建脚本测试:TestKit使用、插件功能测试、任务验证、集成测试、持续集成集成

说实话,很多Android开发者写Gradle脚本都是「写完就跑,能编译过就行」。我以前也这样,直到有一次我写了个自定义插件,本地跑得好好的,结果CI上一跑就炸——原来是我依赖了本地环境变量,而CI机器上压根没设这个变量。从那以后,我再也不敢不写测试了。

这一章,我们就来聊聊怎么给Gradle脚本做测试。说白了,就是让你写的插件、任务、配置,都能被自动化验证,而不是靠「人肉测试」。

为什么需要测试Gradle脚本?

你想想看,一个Android项目里,Gradle脚本控制着编译、打包、签名、混淆、多渠道……几乎每个环节。如果脚本出了问题,轻则编译失败,重则打出一个有问题的包。我见过最惨的一次,是某个插件在特定条件下把签名配置覆盖了,结果线上包签名不对,用户装不上——嗯,那场面,相当酸爽。

所以,给Gradle脚本写测试,不是「锦上添花」,而是「雪中送炭」。它能帮你验证:

  • 插件能不能正常应用
  • 任务能不能正确执行
  • 配置参数有没有被正确解析
  • 不同Gradle版本下行为是否一致

TestKit:Gradle官方测试框架

Gradle官方提供了一个测试框架,叫TestKit。它专门用来测试Gradle插件和构建逻辑。我个人习惯把它和JUnit或Spock搭配使用。

TestKit的核心思路很简单:它会在一个临时目录里创建一个「迷你项目」,然后在这个项目上运行Gradle构建,最后检查构建结果。说白了,就是模拟真实构建环境。

添加TestKit依赖

首先,在你的插件项目的build.gradle里加上TestKit依赖:

dependencies {
    testImplementation gradleTestKit()
    testImplementation 'junit:junit:4.13.2'
}

注意,gradleTestKit()这个方法是Gradle自带的,它会自动引入对应版本的TestKit。

第一个TestKit测试

我们来写一个最简单的测试,验证一个插件能不能被成功应用:

import org.gradle.testkit.runner.GradleRunner
import org.junit.Test
import org.junit.Rule
import org.junit.rules.TemporaryFolder

class MyPluginTest {

    @Rule
    TemporaryFolder testProjectDir = new TemporaryFolder()

    @Test
    void '插件应该能成功应用'() {
        // 准备一个build.gradle文件
        def buildFile = testProjectDir.newFile('build.gradle')
        buildFile << """
            plugins {
                id 'com.example.myplugin'
            }
        """

        // 运行Gradle
        def result = GradleRunner.create()
            .withProjectDir(testProjectDir.root)
            .withArguments('tasks')
            .withPluginClasspath()
            .build()

        // 验证结果
        assert result.task(':tasks').outcome == TaskOutcome.SUCCESS
    }
}

这里有几个关键点:

  • withPluginClasspath():自动把当前项目的classpath注入到测试项目中,这样插件才能被找到
  • withArguments('tasks'):运行tasks任务,列出所有可用任务
  • build():执行构建,如果失败会抛异常
小技巧:如果你只想测试构建是否成功,可以用build()。如果你想测试构建失败的情况,用buildAndFail(),它会返回失败的结果,方便你断言错误信息。

插件功能测试:验证核心逻辑

光验证插件能应用还不够,我们还得验证插件的核心功能。比如,插件注册了一个任务,这个任务能不能正确执行?

假设我们有一个插件,它注册了一个greet任务,输出一句问候语:

class GreetingPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.tasks.register('greet') {
            doLast {
                println "Hello from ${project.name}!"
            }
        }
    }
}

测试代码可以这样写:

@Test
void 'greet任务应该输出问候语'() {
    def buildFile = testProjectDir.newFile('build.gradle')
    buildFile << """
        plugins {
            id 'com.example.greeting'
        }
    """

    def result = GradleRunner.create()
        .withProjectDir(testProjectDir.root)
        .withArguments('greet')
        .withPluginClasspath()
        .build()

    // 检查输出
    assert result.output.contains("Hello from test-project!")
}

这里我用result.output来捕获构建过程中的标准输出。如果任务执行了println,输出里就能找到。

注意:TestKit默认不会捕获System.out的输出。如果你在任务里用了println,它会被捕获。但如果你用了logger.quiet()logger.lifecycle(),这些也会被捕获。只有System.err需要额外配置。

任务验证:检查任务状态和输出

有时候,我们不光要看输出,还要检查任务的状态。比如,某个任务是不是被跳过了?是不是失败了?

TestKit提供了TaskOutcome枚举,可以检查每个任务的执行结果:

@Test
void 'clean任务应该删除build目录'() {
    def buildFile = testProjectDir.newFile('build.gradle')
    buildFile << """
        plugins {
            id 'com.example.myplugin'
        }
    """

    // 先运行一次,生成build目录
    GradleRunner.create()
        .withProjectDir(testProjectDir.root)
        .withArguments('build')
        .withPluginClasspath()
        .build()

    // 再运行clean
    def result = GradleRunner.create()
        .withProjectDir(testProjectDir.root)
        .withArguments('clean')
        .withPluginClasspath()
        .build()

    // 验证clean任务执行成功
    def cleanTask = result.task(':clean')
    assert cleanTask != null
    assert cleanTask.outcome == TaskOutcome.SUCCESS

    // 验证build目录被删除
    assert !new File(testProjectDir.root, 'build').exists()
}

我个人习惯用result.task(':taskName')来获取具体任务的状态。如果任务不存在,它会返回null,所以记得判空。

集成测试:模拟真实项目

单元测试只能验证插件的基本功能。但真实项目里,插件往往要和其他插件、配置、任务协同工作。这时候就需要集成测试了。

集成测试的思路是:创建一个更完整的「模拟项目」,包含多个模块、多种配置,然后运行完整的构建流程。

举个例子,假设我们写了一个代码生成插件,它会在编译前生成一些Java文件。集成测试可以这样写:

@Test
void '代码生成插件应该能在多模块项目中正常工作'() {
    // 创建settings.gradle,包含两个子模块
    def settingsFile = testProjectDir.newFile('settings.gradle')
    settingsFile << """
        rootProject.name = 'test-project'
        include 'app'
        include 'library'
    """

    // 创建app模块的build.gradle
    def appDir = new File(testProjectDir.root, 'app')
    appDir.mkdirs()
    def appBuildFile = new File(appDir, 'build.gradle')
    appBuildFile << """
        plugins {
            id 'com.example.codegen'
            id 'java'
        }
    """

    // 创建library模块的build.gradle
    def libDir = new File(testProjectDir.root, 'library')
    libDir.mkdirs()
    def libBuildFile = new File(libDir, 'build.gradle')
    libBuildFile << """
        plugins {
            id 'com.example.codegen'
            id 'java'
        }
    """

    // 运行编译
    def result = GradleRunner.create()
        .withProjectDir(testProjectDir.root)
        .withArguments('compileJava')
        .withPluginClasspath()
        .build()

    // 验证两个模块都生成了代码
    assert new File(appDir, 'build/generated/source/codegen').exists()
    assert new File(libDir, 'build/generated/source/codegen').exists()
}

集成测试跑起来比较慢,所以我一般只在CI上跑,本地开发时只跑单元测试。

持续集成集成:让测试自动化

测试写好了,怎么让它自动跑起来?当然是集成到CI里。

我常用的CI工具是GitHub Actions和Jenkins。配置起来其实很简单,核心就是让CI执行./gradlew test

以GitHub Actions为例,一个典型的配置是这样的:

name: Gradle Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
    - name: Run tests
      run: ./gradlew test --no-daemon

这里有几个要点:

  • --no-daemon:在CI上建议关闭Gradle守护进程,避免资源占用问题
  • --stacktrace:如果测试失败,打印完整的堆栈信息,方便排查
  • 缓存.gradle目录:可以加速后续构建
核心要点:CI上的Gradle测试,一定要保证环境干净。我遇到过最坑的一次,是本地测试依赖了某个全局安装的工具,CI上没有,结果测试全挂。所以,尽量用withPluginClasspath(),不要依赖外部环境。

知识体系总览

下面这张图,把本章的核心知识点串起来了:

构建脚本测试知识体系 Gradle脚本测试 TestKit框架 插件功能测试 任务验证 集成测试 持续集成集成 GradleRunner TaskOutcome 多模块模拟 GitHub Actions

常见问题与避坑指南

最后,分享几个我踩过的坑:

  • TestKit版本不匹配:我曾经用Gradle 7.0的TestKit去测试一个为Gradle 6.x写的插件,结果一堆API找不到。后来我养成了一个习惯:测试用的Gradle版本,一定要和插件目标版本一致。可以用withGradleVersion('6.9')指定版本。
  • 临时目录清理:TestKit默认不会清理临时目录。如果测试跑多了,磁盘上会留下一堆垃圾。我一般会在@After方法里手动清理,或者用TemporaryFolderdelete()方法。
  • 测试速度:每个TestKit测试都要启动一个Gradle进程,速度很慢。我建议把测试分组,单元测试用JUnit跑,集成测试单独用一个task跑,只在CI上执行。

嗯,关于构建脚本测试,就聊这么多。记住一句话:测试不是负担,而是保护网。有了它,你改脚本的时候才敢放心大胆地重构。

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