在这篇关于 Airbnb 自动化测试框架系列的第七篇也是最后一篇中,我们将继续深入探讨CI 设置,并分享后续发展方向的想法。
在之前的文章中,我们已经研究了 Airbnb 的产品架构、为它构建的 mock 系统以及这个测试生态系统对产品功能运行自动化测试的具体方法。但一个值得注意的缺失部分是我们没有讨论如何以及何时运行这些测试。
我们现在来详细拆解一下把所有这些测试基础设施联系在一起的工具,看看这个工具是怎么为我们的工程师带来愉快的开发体验。
生成测试文件
正如本系列前面提到的,我们为 Fragment 定义了 mock 数据,但不需要我们的工程师编写常规测试代码来测试这些 mock。实际上这些测试代码都是自动生成的。我们将在这仔细研究这在实践中意味着什么。
首先,我们使用 Kotlin 脚本来解析我们的应用目录并提取所有 MvRx Fragment 的名称。
该脚本利用 Kotlin 编译器为每个 Kotlin 文件的生成一个 AST,以便更轻松地检测到Fragment 类
通过 lint 规则,我们在 Fragment 上强制执行命名约定,这样检测 Fragment 类就变得更容易了
我们预先收集了所有 Fragment 类的名称,这是为了化繁为简地实现检测。但如果该Fragment 不是 MvRx Fragment,它会在测试运行时被跳过。
一旦我们有了 Fragment 类名,脚本就会使用 KotlinPoet 将 JUnit 测试文件写入我们的测试源目录也就是在 AndroidTest 文件夹,这样做之后,测试构建程序就会包含这些测试文件。生成的文件如下所示:
class MvrxIntegrationTests : MvRxIntegrationTestBase() {
fun booking_fragments_BookingFragment_Screenshots() {
runScreenshots("com.airbnb.android.booking.fragments.BookingFragment")
}
fun booking_fragments_BookingFragment_Interactions() {
runInteractionTest("com.airbnb.android.booking.fragments.BookingFragment")
}
// More Fragments declared below...
}
可以看到每个测试的名称都基于 Fragment 的完全限定名(fully qualified name),并且每个测试函数只需提供一个 Fragment 名称即可开始测试。运行测试的所有代码都存在于基类中,因此它不需要被包含在生成的文件中。可以查看上一篇文章去了解有关此基类和运行这些测试的 Activity 的详细信息。
这种代码生成方法有几个优点:
消除了为每个 Fragment 手动创建和维护测试的开销
这使得将我们的测试工作拆分成脚本能够处理的任何块变得非常轻量——这对于支持测试分片和确保测试的可扩展性非常重要。比如有一种简单的方法是在一个不可扩展的测试中一起运行所有的Fragment
使得添加新测试类型变得非常轻量。当我们想在屏幕截图测试之外添加其它交互测试时,我们所要做的不过就是在脚本中添加几行代码来为每个 Fragment 生成一个新的附加函数而已。
同样值得注意的是我们做出的一些设计决策:
由于我们的 mock 框架使用自定义 DSL,如果用该脚本检测每个 Fragment 所有 mock 变体的名称,操作变得异常复杂。所以实际操作中,我们只收集 Fragment 的名称。这意味着每个测试函数必须为 Fragment 运行所有 mock,而不能够将每个 mock 分片到自己的测试中。大多数情况下这都没问题,但有时可能会导致那些具有许多 mock 变体的 Fragment 花费更长的测试时间。为了解决这个问题,我们实现了一个用于将 mock 分组的基础系统,该系统被设计成能通过脚本来轻松解析。
override fun provideMocks() = combineMocks(
"Marketplace" to marketplaceMocks(),
"Plus" to plusMocks(),
"Plus ProHost" to plusProHostMocks()
)
脚本对每个 Kotlin 文件的 AST 表示是有限的——它只能孤立地查看每个文件,而不能找到它们的引用链。例如,如果这个属性的类型是在另一个文件中定义的,我们就不能轻易地知道它的类型。这也就意味着脚本需要做出一些可能容易出错的最佳猜测。在理想情况下,我们可以编译和运行整个应用程序,并以编程方式访问我们需要的任何信息——这将允许我们以有保证的方式收集所有 MvRxFragment 和声明的 mock 的详细信息(类似于 MvRx 启动器的工作方式)。但是这样做会显著增加流程的复杂性和运行时间,而这两者都是我们正在努力减少的。在实践中,Kotlin 脚本方案非常好地满足了我们的需求,并且它只需几秒钟即可运行完。
JUnit 支持参数化测试,而且这些可以在运行时以编程的方式声明。这几乎是我们用于生成测试的 Kotlin 脚本方案的完美替代方案,但是参数化测试不适用于测试分片,因此它会妨碍我们轻松扩展测试的能力。
类似地可以使用一种简单的方法来手动定义对 Fragment 进行分组的测试,例如对字母表中的每个字母开头的所有 Fragment 的测试。这在一定程度上可行,但这不容易扩展也不能在分片之间均匀分布测试。
可以构建自定义注解处理系统,Fragment 被打上可以在编译期能被检测到的注解,并使用该注解来生成测试文件。这样做的准确性非常好,但它有一些缺点:1)需要样板代码来注解每个Fragment,而不是允许自动检测。2) 注解处理器会增加编译时间。3) 测试文件依赖于编译过程而不能被静态生成。
这个测试生成脚本在我们的 CI 测试 job开始时运行,因此在我们构建项目之前,测试源文件就存在于项目目录中。有关 CI 配置的更多详细信息,请参阅下面我们 CI job的章节。
总的来说,我们的脚本方法实现了以下目标:
在可接受的粒度和准确性下以编程的方式检测 Fragment 及其 mock 数据
支持有效扩展测试,并在测试分片之间公平分配测试时间
配置测试运行时不会给 CI job 增加太多时间
使我们能够轻松添加新的测试类型
不会给项目增加太多复杂性
将开发人员为 Fragment 创建 mock 测试的开销降至最低
使我们能够仅对被更改的 Fragment 运行测试
集成测试的 CI 基础设施
我们的测试套件运行在我们 Github 仓库的每个PR上。我们使用 Buildkite 来运行 CI,为不同测试类型添加任意数量的 pipeline 因此变得容易——例如,单元测试和集成测试运行在不同的 pipeline job 中,因此它们可以并行运行。由于本系列文章专注于集成测试,因此我们将只了解该 pipeline 是如何工作的。
在拉取我们的 APP 代码库后,我们首先运行上面解释过的测试生成脚本。这会解析我们的项目以找到那些应该在测试中使用到的 Fragment 和其 mock,并在我们的 androidTest 目录中生成一个 JUnit 测试源文件。之前没有提到的是,这个脚本也很聪明地知道哪些 Fragment 应该包含在测试中。
检查更改过的Fragment
我们的 pipeline 将 PR 分支与其要去合并的分支进行比较,并检查哪些文件有更改。然后根据更改的文件来确定哪些模块受到影响。测试生成脚本会将那些不受影响的 Fragments 排除在外,从而允许我们仅在那些可能已有行为更改的 Fragment 上运行更改检查。这使我们能够更快地运行测试,并节省 Firebase的成本。
但如果采用这种方法,需要注意一些棘手的事情:
必须考虑模块的依赖关系。如果模块 A 包含一个已更改的文件,而模块 B 依赖于模块 A,那么我们必须确保模块 B 也包含在我们的测试中。这需要使用模块依赖图来确定这些更改。
外部依赖项更改也会影响模块。如果你更改正在使用的库的版本,则应测试依赖于该库的所有模块。为简单起见,我们有一个文件,其中声明了所有依赖项版本,如果该文件有任何更改,我们将运行所有测试。
处理 Firebase 中断
至此,我们已经生成了一个带有 Espresso 入口点的测试文件来测试受 PR 更改影响的 Fragment。我们首先需要检查 Firebase 有没有停机,再运行命令来构建并测试应用 apk。
我们的集成测试都在 Firebase 上运行,如果它因为事故而停机,测试可能就会失败。这对开发人员的生产效率有一定的负面影响:
开发人员对失败的原因以及他们应该做什么感到困惑
向我们团队提出的大量帮助请求
不幸的是Firebase 经常不稳定,这对我们来说的确是一个问题,所以我们有一种自动化的方法来处理它。我们的pipeline使用 Firebase 状态 API通过 Firebase Test Lab 去检查正在发生的事故,并在检测到事故时向相关PR 发布评论。该评论包括事件的链接以及有关开发人员应如何处理它的说明。
使用 Flank 进行分片
现在我们已经生成了测试文件,我们的测试 apk 也已经构建完,并且已经准备好在 Firebase 上实际运行我们的测试了。借助 Firebase 的 gcloud 命令行使得这相当简单,但这将在单个测试矩阵中串行运行所有测试。如果你有很多测试,这可能需要很长时间,并且随着时间的推移不能很好地扩展。
值得庆幸的是,一个名为 Flank 的开源库可以帮助我们将测试拆分为多个分片,这些分片能在多个 Firebase 测试矩阵上并行运行。我们完整的测试套件总测试时间大约需要两个小时,但在分片后只需运行几分钟(不包括 setup 和 teardown 时间)。
Flank 的文档相当直截了当,所以我不会详细介绍我们的设置。我们的 pipeline 只是使用一个脚本来生成一个包含我们想要的配置的 flank.yml 文件,其余的都由 Flank 处理。
需要注意的一件事是,你选择测试的设备可能会在测试时间上有很大的不同。对我们来说,模拟器比物理设备慢很多,所以我们只在物理设备上进行测试以减少测试时间。此外,设备的类型会极大地影响测试时间。我们最初没有考虑太多直接使用 Google Pixel 进行测试,后来对 Pixel 3 的实验表明,它运行我们测试的速度大约是我们的两倍。回想起来,这并不奇怪,但它很好地提醒我们要关注使用的测试设备。
结果处理
一旦测试开始运行,我们在 pipeline 中的最后一个职责就是将结果传回 PR。
测试失败
在测试失败时,一种简单的方法是在 Flank 因任何错误退出时让整个 CI job 失败。然而这最终会使得开发人员几乎无法了解失败的原因,然后他们不得不花时间深入研究CI 日志以找到指向正确 Firebase 测试矩阵的链接——这对于分片测试来说更加困难,因为它并不是都标注了哪些分片测试失败了。
自动化工具在这里可以再次发挥作用,这样我们就可以尽可能地减少开发人员的阻碍。我们的pipeline脚本执行以下操作:
Flank 的所有输出文件上传为 Buildkite Artifacts,以便它们在进行调试时易于访问
Flank 的 JUnit 报告以收集包含失败的测试矩阵列表
PR 发布评论,其中包含指向失败矩阵的链接
一个链接到测试失败的 Firebase Test Lab矩阵的PR评论
这允许开发人员直接从 PR 中轻松访问到 Firebase 失败的信息。它还允许我们包含指向有关开发人员应如何处理故障以及如何使用 Firebase 的文档的链接,这对于我们的贡献者数量不断变多非常重要。
生成 Happo 报告
之前的文章深入讨论了我们使用 Happo 为屏幕截图和交互细节生成差异报告。这个 Happo 集成测试是我们 CI job 的最后步骤之一——脚本将刚刚在测试中生成的 Happo 报告与 master 分支上的最新报告进行比较。如果有任何差异,则会有一个评论会被发布回 PR 并提供相关详细信息。
一个指出检测到有视觉变化的Happo评论
在合并 PR 之前,开发人员必须点击链接检查差异结果并确认更改是有意为之的(有关此批准测试方法的更多详细信息,请参阅本系列的前几篇文章)。
值得一提的是,Happo 与分片测试配合得很好。每个分片上传自己的报告,Happo 将所有这些部分报告组合成能代表所有 Fragment 的最终报告。然而一个复杂的问题是,当我们的测试只包括受那些受更改的 Fragment 时,最终报告将是不完整的。由于未更改的 Fragment 不被包含在测试中,这导致在 Happo 报告中缺少这些 Fragment,最终 Happo 报告会显示它们都已被删除了。
我们这里的解决方案是用一个脚本,它从 master 中提取最新的 Happo 报告,并使用未更改的 Fragment 列表将这些屏幕中的详细信息复制到新报告中。这很好用,但有一个小问题:我们必须确保主报告已被完全创建。如果 PR 最近 rebase 过,很有可能为最新的commit 生成主报告的 CI job 仍在运行,为了避免这种竞争条件,所以我们将这一步保留到了最后。
代码覆盖率报告
集成测试 pipeline 的最后一个职责是获取代码覆盖率数据,并为 PR 计算代码覆盖率报告。我们使用标准的 Jacoco 工具,可以直接与 Flank 和 Firebase 集成。但是由于我们在单独的 pipeline 中运行集成测试和单元测试,并且需要组合这些报告以获得绝对覆盖率数据,因此会出现一些复杂性。
一份覆盖率报告会发回 PR 以向开发人员提供清晰的信息。我们仍处于构建代码覆盖率工具的早期阶段,并希望随着时间的推移改进我们的功能。
PR 评论
在很多地方,我已经提到我们的 CI pipeline将评论发布回 PR,以便向开发人员清楚地展示信息。这是通过我们构建的工具来完成的,该工具可以轻松地使任何 pipeline 通过调用一个简单的 API 发表评论。如果我们想要更改一些内容,它会更新评论,或者如果这个评论不再适用则它也能将其删除,这在推送新 commit 和重新运行 job 时都很重要。
该用于创建和删除评论的工具构建在 Github 的 API 之上,并抽象出需要直接使用该 API 的细节。此外,Github 并没有提供更新评论的简单方法,所以当我们想要“更新”它时,我们需要删除以前的评论并添加一个新的评论。为此,我们的工具要求每条消息都与一个字符串键相关联,并将一个 Github 评论 ID 与 AWS 数据库中的该字符串键相关联。通过这种方式,它可以查找该键可能已经存在的评论,并获取删除它所需的 id。
这个管理评论的工具对我们非常有帮助。有了它,我们能更简单地使用 CI pipeline 以清晰的方式向用户展示信息,同时降低 pipeline 本身的复杂性。
结束语
至此,希望您已经很好的理解了我们测试 Android 代码的理念、我们如何构建系统来帮助我们更容易和更全面地测试。这些系统中的大多数成果都是在过去一年中建成的,这是令人欣喜的成果而我们也将继续关注这个话题,不断尝新进行下一步的探索。
未来的改进
我们现有的测试套件涵盖了大部分代码路径,但并不完美。我们正在计划改进它可以测试的用例。值得庆幸的是,我们的 mock 架构可以轻松地在其上构建新的测试系统。
我们想探索的一些领域是:
Deep link 的自动化测试
通过多个屏幕运行并能命中生产环境 API 的端到端测试
支持利用 mock 框架的手动和自定义的 Espresso 测试
通过新的 Jetpack 基准库自动进行性能基准测试
自动支持测试其他常见代码路径,例如 EditText 输入和 onActivityResult 实现
测试优化后的构建(R8/Proguard)以捕捉仅在线上 APP 中出现的问题
开源计划
我们为我们所做的测试工作感到自豪,很高兴能与大家分享!它被设计成与 MvRx 集成,因此它也是该开源库的自然而然的扩展。我们正在发布这些测试框架以作为 MvRx 的补充(从 2.0.0 alpha 版本开始),无比期待来自社区的反馈和贡献。
文章系列索引
本系列文章将系统性介绍爱彼迎 Android 的自动化测试,共包含 7 篇文章。我们将陆续为您推送。
第 1 部分 - 自动化测试的原理与状态模拟系统
第 2 部分 - 利用 MvRx 和 Happo 做自动化截屏测试
第 3 部分 - 自动化交互测试
第 4 部分 - ViewModel 单元测试的框架
第 5 部分 - 爱彼迎自动化测试框架的架构
第 6 部分 - 如何保持模拟的一致性
第 7 部分 - 测试代码生成及持续集成的配置(本篇文章)
原文作者:Eli Hart,译者:Tiantao Zhu,校对:Yifan Zhu,Betty Xi。
特别感谢所有项目的参与者,以及相关开源库的贡献者。
感谢阅读!如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)。