PAYATHON 2026

Bitrise gradle-runner 因单元测试失败导致构建失败

支付阿杰

结论

是的,这次构建失败的直接原因是 4 个单元测试没有通过。

日志已经给出了完整的因果链:

580 tests completed, 4 failed
> Task :app:testDebugUnitTest FAILED
Execution failed for task ':app:testDebugUnitTest'.
> There were failing tests.
BUILD FAILED
Gradle task failed, error: exit status 1

Gradle 执行 :app:testDebugUnitTest 时发现测试失败,任务因此返回非零退出码。Bitrise 的 gradle-runner 收到退出码 1,随后将整个 Step 和构建标记为失败。

所以,这不是 Bitrise 本身的故障,也不是 gradle-runner 的版本更新提示造成的。

变量解析错误和 Kotlin 版本警告并不是日志中记录的最终失败原因,但也不宜长期搁置。特别是 Kotlin 运行库版本不一致,可能带来编译或运行时问题。

首先修复失败的测试

失败用例集中在:

EnterCardDetailsViewModelSpec

其中包括:

it should validate ExpiryDate
it should emit continue button state

最后一个断言指向:

EnterCardDetailsViewModelSpec.kt:177

先在本地执行与 CI 相同的 Gradle 任务:

./gradlew :app:testDebugUnitTest

也可以只运行相关测试类,缩短排查时间:

./gradlew :app:testDebugUnitTest \
  --tests "com.karmarama.edf.view.dashboard.payment.card.details.EnterCardDetailsViewModelSpec"

如果需要更多上下文,可以运行:

./gradlew :app:testDebugUnitTest --info --stacktrace

排查时重点关注:

  • EnterCardDetailsViewModelSpec.kt:177 中的预期值和实际值。
  • ExpiryDate 测试使用的日期是否依赖当前年月。
  • CI 和本地的时区、Locale 或系统时间是否不同。
  • 异步任务、RxJava 调度器或协程是否在断言前执行完毕。
  • 不同测试之间是否共享了可变状态。
  • 测试依赖的环境变量、配置文件或密钥是否只存在于本地。

日期有效性测试很容易随着时间推移而失效。例如,测试中写死的有效期在编写时尚未过期,几个月后却可能变成无效数据。更稳妥的做法是使用固定时钟,或者通过明确注入的当前日期来生成测试数据,不要直接读取系统时间。

概念上可以采用类似的结构:

class EnterCardDetailsViewModel(
    private val clock: Clock
) {
    // 使用 clock 获取当前时间
}

测试时传入固定时间:

val fixedClock = Clock.fixed(
    Instant.parse("2025-01-15T00:00:00Z"),
    ZoneOffset.UTC
)

具体实现仍需结合项目使用的日期类型和 Android API 兼容要求调整。仅凭当前日志,还不能确定问题一定与时间有关。

查看测试报告

日志提供的报告路径是:

file:///Users/vagrant/git/app/build/reports/tests/testDebugUnitTest/index.html

这是 Bitrise 构建机器上的本地路径,通常无法直接从自己的电脑打开。可以选择以下方式处理:

  • 在 Bitrise Workflow 中配置测试报告收集或部署步骤。
  • 将 app/build/reports/tests/testDebugUnitTest/ 作为构建产物上传。
  • 在本地重现问题,再打开对应路径下的 index.html。

测试报告通常包含 expected/actual、完整堆栈和各个失败用例的输出,比主构建日志更适合定位问题。

处理变量解析错误

日志中还有这些信息:

Failed to resolve variable '${project.groupId}'
Failed to resolve variable '${project.version}'
Failed to resolve variable '${version.asm}'
Failed to resolve variable '${version.junit}'
Failed to resolve variable '${version.mockito}'

这些错误很可能来自某个依赖的 Maven POM 元数据,或者是 Android Jetifier 处理依赖时遇到了未展开的 Maven 属性。由于构建之后仍然完成了依赖转换和编译,并进入单元测试阶段,所以从当前日志判断,它们没有直接导致这次构建中止。

可以先查明这些变量来自哪个依赖:

./gradlew :app:dependencies

针对测试运行时依赖,可以继续执行:

./gradlew :app:dependencyInsight \
  --dependency junit \
  --configuration debugUnitTestRuntimeClasspath

其他变量也可以分别检查对应依赖,例如:

./gradlew :app:dependencyInsight \
  --dependency asm \
  --configuration debugUnitTestRuntimeClasspath

找到来源后,可以采取以下措施:

  • 升级生成异常 POM 的直接依赖。
  • 将过旧或重复的测试库统一到兼容版本。
  • 如果依赖来自已经废弃的仓库,改用仍在维护的发布源或版本。
  • 检查仓库中发布的 POM 是否完整,不要直接在项目中随意定义同名 Gradle 变量。

不要仅仅为了消除日志,就在 gradle.properties 中添加 project.groupId、version.junit 等变量。它们很可能是依赖 POM 内部的 Maven 占位符,未必应该由当前项目提供。

统一 Kotlin 依赖版本

日志显示,运行时 classpath 中同时存在:

kotlin-reflect:1.2.60
kotlin-stdlib:1.3.21
kotlin-stdlib-common:1.3.21

这是实际存在的依赖一致性问题。虽然目前只产生了警告,但混用 Kotlin 1.2 和 1.3 运行库,可能导致二进制兼容、反射或运行时行为异常。

先查明 kotlin-reflect:1.2.60 是由哪个依赖引入的:

./gradlew :app:dependencyInsight \
  --dependency kotlin-reflect \
  --configuration debugUnitTestRuntimeClasspath

如果它由项目直接声明,应将其调整为项目正在使用的 Kotlin 版本:

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
}

如果旧版本由第三方测试框架传递引入,优先升级该框架。必要时,可以排除旧的传递依赖,再显式声明统一版本:

testImplementation("example:testing-library:version") {
    exclude group: "org.jetbrains.kotlin", module: "kotlin-reflect"
}

testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

这里的 example:testing-library:version 只是结构示例,必须替换为 dependencyInsight 实际查到的依赖,不能直接照搬。

是否需要升级 gradle-runner

日志中的:

Update available: 1.8.4 -> 1.8.5

只是 Step 的版本更新提示。这次失败已经明确出现在 Gradle 测试任务中,没有证据表明升级 gradle-runner 可以修复这 4 个失败的断言。

可以在后续维护中评估升级,但建议按照以下顺序处理:

  1. 修复或确认 4 个失败测试。
  2. 在本地执行与 Bitrise 相同的 Gradle 任务。
  3. 推送修改并重新触发构建。
  4. 清理变量解析错误和 Kotlin 版本冲突。
  5. 单独升级 Bitrise Step,并比较升级前后的构建行为。

不要通过忽略测试失败来让 PR 通过。Gradle 虽然可以配置 ignoreFailures,但这样会削弱 CI 拦截回归问题的能力。除非团队已经明确将这项任务定义为非阻断检查,否则失败测试仍应导致构建失败。

备注:内容仅供参考。