本文是全球在线旅游及服务供应商Agoda的工程师Karthik Periasami分享的一些实践经验,希望给大家提供参考。
以下为作者观点:
开发人员经常发现自己被冗长的 CI 流程拖慢了脚步,尤其是在等待UI/集成测试来验证他们的工作时。这一瓶颈会大大延迟新功能或修复的合并。本文将探讨通过选择性测试改进 CI/CD 管道的简化方法,在提高效率的同时保持测试流程的质量和完整性。
问题:全面UI测试导致CI缓慢
在Agoda的移动应用程序中,UI测试非常繁重,因为我们需要实例化大量的库,如实验、分析、核心 API 等,以建立一个测试应用程序。因此,我们决定使用单个测试应用程序来进行近 900 次 UI 测试,这些测试涉及各种功能。
UI测试应用程序的架构
要初始化测试应用程序,必须在执行任何测试之前配置许多实用程序。这成为将测试应用程序模块化为特定功能的测试应用程序的巨大障碍。因此,开发人员习惯于将他们的功能测试合并到这个单一的应用程序中。
这种做法付出了高昂的代价。确定哪些测试依赖于哪些功能变得越来越具有挑战性,这导致我们在每次更改的持续集成 (CI) 系统中执行所有 UI 测试,以确保没有任何问题。这种测试执行策略虽然彻底,但也带来了一些挑战:
-
测试不稳定:测试中的随机失败通常会阻止拉取请求,需要多次重试才能成功合并。开发人员平均必须重试至少三次才能合并他们的更改。
-
交付时间延迟:交付生产变更的平均时间相当长,超过2.5天。
-
开发人员的负面体验:开发人员经常遇到与他们的更改无关的测试失败,这对他们的体验产生了负面影响。
解决方案:实施选择性测试
为了解决这些问题,我们必须从开发人员的角度出发,典型工作流程如下:
-
编写代码并测试其功能。
-
运行他们认为受到这些变化影响的特定测试。
-
将其推送到 Git 并尝试合并这些更改。
考虑到执行时间长和所需的复杂环境设置(例如外部模拟服务、模拟器等),执行所有UI 测试是不切实际的。因此,通常只运行他们认为需要的特定测试。受其代码更改的影响。
这让我们怀疑是否可以在 CI 上做同样的事情。
主要挑战是确定受拉取请求更改影响的测试。为了解决这个问题,我们需要确定每个测试执行哪些代码段。 我们都知道有一种工具可以捕获这些确切的元数据。它是一个代码覆盖率工具,代码覆盖工具捕获执行测试时命中的所有代码并将该信息记录在文件中。
你可能熟悉JaCoCo、SonarQube、NCover和JSCoverage等工具,每个工具都有其独特的优点和缺点以及它们支持的语言。我们选择了 JaCoCo,它已用于报告 Android 存储库中单元测试的代码覆盖率。此外,事实证明,使用 JaCoCo 解析生成的 exec 文件非常简单。
相关链接:
https://www.jacoco.org/jacoco/
https://www.sonarsource.com/products/sonarqube/
https://www.ncover.com/
https://github.com/fishbar/jscoverage
Android UI测试中的代码覆盖率如何发挥作用?
要启用UI测试的覆盖率,我们只需在Gradle中设置一个属性,如下所示。
android {
buildTypes {
调试 {
testCoverageEnabled = true
}
}
}
当我们在项目中运行连接测试时,检测运行器利用 Emma 覆盖工具来捕获应用程序中执行的每个字节码。
然后,这些字节码被写入应用程序设备存储中的 exec 文件。测试运行完成后,检测运行程序将从设备存储中提取 exec 文件并将其存储在模块的构建目录下。
Android UI测试中的代码覆盖率
JaCoCo 库(通常是一个 Gradle 任务)会解析该执行文件,并根据配置创建 HTML 或 XML 格式的报告。
挑战
这里生成的覆盖文件(.ec) 是基于测试的集体结果。从这个单一的执行文件中无法验证哪个测试执行了哪行代码。因此,我们需要一次运行一个测试,为每个测试分别生成覆盖文件。这就需要清除之前的数据、启动应用程序、运行测试,并从设备中提取每个测试的覆盖数据,耗费大量时间。
Agoda Device Farm的帮助
Agoda Android 应用程序包含超过980个UI 测试,通过一个拥有60台Android 设备的设备池进行管理。我们在设备池上安排这些测试,执行所有测试最多需要15分钟。我们以10个批次执行这些测试,以确保没有测试受到某些全局状态的影响。
为了为每个测试生成一个覆盖率文件,我们将测试配置为以大小为1的批次执行,并在每个批次(基本上每个测试)结束时提取覆盖率文件。
这种配置平均需要50分钟来运行所有测试并提取各自的覆盖文件,即超过35分钟的开销时间。工作结束后,我们得到了如下图所示的工件,其中包含与每个测试分别相关的覆盖率文件。
Agoda Device Farm中的UI测试编排
使用覆盖范围文件
现在最困难的部分已经结束了。我们为每个测试都有单独的覆盖率文件。使用 JaCoCO 库的内置帮助程序可以非常轻松地解析这些覆盖率文件。
/*** 从覆盖文件中获取代码行
* [
* com/packageName/className$MethodName1,
* com/packageName/className$MethodName2
* ]
*/
fun parseToLines (codeCoverageFile: File ) : List<String> {
val loader = ExecFileLoader().also {
it.load(codeCoverageFile)
}
return loader.executionDataStore.contents.map {
it.name
}
}
一旦我们解析了所有覆盖率文件,我们就可以创建覆盖率数据,如下所示,将每一行代码与执行它的测试进行映射。
// CoverageData:与每行代码相关的测试列表的映射。
[
com/packageName/className $MethodName1 : [test1],
com/packageName/className $MethodName2 : [test1, test2]
]
现在,我们使用此覆盖率数据来比较拉取请求中的更改,确定哪些测试受到影响,并仅执行这些测试以将更改合并到主分支中。
生成选择性测试的覆盖率数据
这时出现了一个关键问题:我们应该什么时候生成覆盖率数据?在每个拉取请求中生成它都会违背我们提高效率的目标。相反,我们采用类似于常见缓存机制的策略。
在大多数构建系统中,主分支会生成一个缓存,每个功能分支都使用该缓存来避免构建冗余的东西。
我们从主分支生成这些覆盖数据并将其存储在云中,以便 CI 作业可以访问这些数据。测试选择器在每个拉取请求上下载此覆盖率数据,仅选择受拉取请求更改影响的那些测试,并发送所选测试的列表以在测试作业中运行。
选择性测试工作流程
逃避测试
生成900个UI测试的覆盖率数据需要将近一个小时。想象一下,在此间隔期间,两个拉取请求(PR1和PR2)正在合并。如果PR1向testA添加新的依赖项,则PR2的TestSelector将不会根据旧的覆盖率数据验证testA。
这可能会使testA失败,特别是当后续PR更改testA新添加的依赖项时。这个失败的测试将不会被注意到,直到某些PR更改了testA的某个依赖项中的某些内容,并且一旦新的更新的覆盖率数据可用,测试选择器就会选择testA。
解释覆盖率数据和逃避测试的时间表
保持测试质量
为了解决这个问题,我们受 Gradle 测试缓存方法的启发构建了一个解决方案。仅当测试成功时才会缓存测试。
我们仅通过解析成功的测试并忽略失败的测试来上传覆盖率数据。一旦从主分支上传新的覆盖率数据,所有拉取请求都将被阻止。由于失败的测试不会有任何覆盖率数据,因此测试选择器将这些测试视为新测试,并始终在 CI 管道中运行它们,无论 PR 发生什么变化。
测试选择器选择覆盖数据中缺失的测试
这使开发人员能够及早发现故障。最重要的是,这些失败的测试会通过自动化系统通知测试所有者,等待他们在主分支上解决此故障的响应。他们可以:
1.如果是关键故障,则在临时流程中修复
2.如果失败的测试不稳定,则在测试选择器上标记为忽略,以阻止其他拉取请求合并,并在其 SLA 中修复。
选择性测试结果
目前,选择性测试已在 Agoda 的 Android 存储库上实施,从而显着减少了每个拉取请求运行的测试数量。这种战略方法节省了时间和资源,并显着降低了 CI 管道故障的频率,特别是那些由不相关的片状测试引起的故障。
选择性测试结果
结论
选择性测试已被证明是优化 CI/CD 效率的有效方法。这种方法有利于加快集成和开发周期,并确保维持高质量的测试标准。受到 Android 存储库这些结果的启发,我们计划探索在其他存储库上实施选择性测试的方法。
最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:【文末自行领取】
这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!