Skip to content

构建逻辑单元测试

源:Gradle 官方文档 - Testing Build Logic with TestKit

使用 Gradle TestKit 编写构建逻辑的单元测试,确保自定义任务和插件的正确性。

为什么需要测试构建逻辑

问题场景

  • 自定义任务在升级 Gradle 后失效
  • 插件在某些配置下出错
  • 构建逻辑改动影响未知

解决方案:编写自动化测试。

测试内容

  • 自定义任务行为
  • 插件配置逻辑
  • 多版本兼容性
  • 边界条件处理

Gradle TestKit

什么是 TestKit

Gradle TestKit:官方提供的测试框架,用于测试Gradle构建逻辑。

核心功能

  • 启动真实的 Gradle 构建
  • 在隔离环境中运行
  • 断言构建结果
  • 检查任务状态

添加依赖

build.gradle.kts

kotlin
dependencies {
    testImplementation(gradleTestKit())
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlin:kotlin-test")
}

基本测试

测试简单任务

任务代码

kotlin
// src/main/kotlin/MyPlugin.kt
class MyPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.tasks.register("greet") {
            doLast {
                println("Hello from MyPlugin")
            }
        }
    }
}

测试代码

kotlin
// src/test/kotlin/MyPluginTest.kt
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class MyPluginTest {
    @TempDir
    lateinit var testProjectDir: File
    
    @Test
    fun `test greet task`() {
        // 创建测试项目
        File(testProjectDir, "settings.gradle.kts").writeText("""
            rootProject.name = "test"
        """.trimIndent())
        
        File(testProjectDir, "build.gradle.kts").writeText("""
            plugins {
                id("my.plugin")
            }
        """.trimIndent())
        
        // 运行构建
        val result = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("greet")
            .withPluginClasspath()
            .build()
        
        // 断言
        assertEquals(TaskOutcome.SUCCESS, result.task(":greet")?.outcome)
        assertTrue(result.output.contains("Hello from MyPlugin"))
    }
}

GradleRunner API

基本配置

kotlin
val result = GradleRunner.create()
    .withProjectDir(testProjectDir)      // 项目目录
    .withArguments("taskName", "--info") // 执行参数
    .withPluginClasspath()                // 插件classpath
    .build()                              // 运行并期望成功

运行选项

kotlin
// 期望成功
.build()

// 期望失败
.buildAndFail()

// 调试模式
.withDebug(true)

// 指定 Gradle 版本
.withGradleVersion("8.5")

// 启用配置缓存
.withArguments("task", "--configuration-cache")

检查结果

kotlin
val result = runner.build()

// 任务状态
result.task(":taskName")?.outcome
// TaskOutcome.SUCCESS
// TaskOutcome.FAILED
// TaskOutcome.UP_TO_DATE
// TaskOutcome.FROM_CACHE
// TaskOutcome.SKIPPED
// TaskOutcome.NO_SOURCE

// 输出内容
result.output.contains("expected text")

// 构建成功/失败
result.task(":taskName") != null

测试自定义任务

任务代码

kotlin
@CacheableTask
abstract class GenerateFileTask : DefaultTask() {
    @get:Input
    abstract val content: Property<String>
    
    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    
    @TaskAction
    fun generate() {
        outputFile.get().asFile.writeText(content.get())
    }
}

class MyPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.tasks.register<GenerateFileTask>("generateFile") {
            content.set("Hello, World!")
            outputFile.set(target.layout.buildDirectory.file("output.txt"))
        }
    }
}

测试代码

kotlin
class GenerateFileTaskTest {
    @TempDir
    lateinit var testProjectDir: File
    
    @Test
    fun `test file generation`() {
        // 准备
        File(testProjectDir, "settings.gradle.kts").writeText("")
        File(testProjectDir, "build.gradle.kts").writeText("""
            plugins {
                id("my.plugin")
            }
            
            tasks.named<GenerateFileTask>("generateFile") {
                content.set("Custom content")
            }
        """.trimIndent())
        
        // 执行
        val result = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("generateFile")
            .withPluginClasspath()
            .build()
        
        // 断言
        assertEquals(TaskOutcome.SUCCESS, result.task(":generateFile")?.outcome)
        
        val outputFile = File(testProjectDir, "build/output.txt")
        assertTrue(outputFile.exists())
        assertEquals("Custom content", outputFile.readText())
    }
    
    @Test
    fun `test task cacheability`() {
        File(testProjectDir, "settings.gradle.kts").writeText("")
        File(testProjectDir, "build.gradle.kts").writeText("""
            plugins {
                id("my.plugin")
            }
        """.trimIndent())
        
        // 第一次运行
        val result1 = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("generateFile", "--build-cache")
            .withPluginClasspath()
            .build()
        
        assertEquals(TaskOutcome.SUCCESS, result1.task(":generateFile")?.outcome)
        
        // 清理后再次运行
        File(testProjectDir, "build").deleteRecursively()
        
        val result2 = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("generateFile", "--build-cache")
            .withPluginClasspath()
            .build()
        
        // 应该从缓存加载
        assertEquals(TaskOutcome.FROM_CACHE, result2.task(":generateFile")?.outcome)
    }
}

测试插件配置

测试扩展

kotlin
interface MyExtension {
    val message: Property<String>
}

class MyPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        val extension = target.extensions.create<MyExtension>("myConfig")
        extension.message.convention("default")
        
        target.tasks.register("printMessage") {
            doLast {
                println(extension.message.get())
            }
        }
    }
}

测试

kotlin
@Test
fun `test extension configuration`() {
    File(testProjectDir, "build.gradle.kts").writeText("""
        plugins {
            id("my.plugin")
        }
        
        myConfig {
            message.set("Custom message")
        }
    """.trimIndent())
    
    val result = GradleRunner.create()
        .withProjectDir(testProjectDir)
        .withArguments("printMessage")
        .withPluginClasspath()
        .build()
    
    assertTrue(result.output.contains("Custom message"))
}

@Test
fun `test default extension value`() {
    File(testProjectDir, "build.gradle.kts").writeText("""
        plugins {
            id("my.plugin")
        }
    """.trimIndent())
    
    val result = GradleRunner.create()
        .withProjectDir(testProjectDir)
        .withArguments("printMessage")
        .withPluginClasspath()
        .build()
    
    assertTrue(result.output.contains("default"))
}

多版本测试

测试多个 Gradle 版本

kotlin
class MultiVersionTest {
    @ParameterizedTest
    @ValueSource(strings = ["7.6", "8.0", "8.5"])
    fun `test plugin with multiple gradle versions`(gradleVersion: String) {
        File(testProjectDir, "build.gradle.kts").writeText("""
            plugins {
                id("my.plugin")
            }
        """.trimIndent())
        
        val result = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("myTask")
            .withPluginClasspath()
            .withGradleVersion(gradleVersion)
            .build()
        
        assertEquals(TaskOutcome.SUCCESS, result.task(":myTask")?.outcome)
    }
}

测试失败场景

测试错误处理

kotlin
class MyPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.tasks.register("validateInput") {
            doLast {
                val input = project.findProperty("input") as? String
                require(!input.isNullOrBlank()) {
                    "Input cannot be empty"
                }
            }
        }
    }
}

测试

kotlin
@Test
fun `test task fails with invalid input`() {
    File(testProjectDir, "build.gradle.kts").writeText("""
        plugins {
            id("my.plugin")
        }
    """.trimIndent())
    
    val result = GradleRunner.create()
        .withProjectDir(testProjectDir)
        .withArguments("validateInput")
        .withPluginClasspath()
        .buildAndFail()  // 期望失败
    
    assertTrue(result.output.contains("Input cannot be empty"))
}

实战案例

案例1:测试 Android 约定插件

kotlin
class AndroidLibraryPluginTest {
    @TempDir
    lateinit var testProjectDir: File
    
    @Test
    fun `test android library configuration`() {
        File(testProjectDir, "settings.gradle.kts").writeText("")
        
        File(testProjectDir, "build.gradle.kts").writeText("""
            plugins {
                id("my.android.library")
            }
        """.trimIndent())
        
        // 创建必要的 Android 文件
        File(testProjectDir, "src/main").mkdirs()
        File(testProjectDir, "src/main/AndroidManifest.xml").writeText("""
            <manifest package="com.example.test" />
        """.trimIndent())
        
        val result = GradleRunner.create()
            .withProjectDir(testProjectDir)
            .withArguments("assembleDebug")
            .withPluginClasspath()
            .build()
        
        assertEquals(TaskOutcome.SUCCESS, result.task(":assembleDebug")?.outcome)
    }
}

最佳实践

使用 @TempDir

kotlin
@TempDir
lateinit var testProjectDir: File  // JUnit 自动清理

提取通用设置

kotlin
private fun setupProject(buildScript: String) {
    File(testProjectDir, "settings.gradle.kts").writeText("")
    File(testProjectDir, "build.gradle.kts").writeText(buildScript)
}

测试增量构建

kotlin
@Test
fun `test incremental build`() {
    // 第一次构建
    val result1 = runner.build()
    assertEquals(TaskOutcome.SUCCESS, result1.task(":task")?.outcome)
    
    // 第二次构建(无变化)
    val result2 = runner.build()
    assertEquals(TaskOutcome.UP_TO_DATE, result2.task(":task")?.outcome)
}

测试配置缓存

kotlin
@Test
fun `test configuration cache compatibility`() {
    val result = GradleRunner.create()
        .withArguments("task", "--configuration-cache")
        .build()
    
    assertFalse(result.output.contains("Configuration cache problems"))
}

CI 集成

yaml
- name: Run Plugin Tests
  run: ./gradlew test