构建逻辑单元测试
源: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