关于我们在 Airbnb 爱彼迎打造 Android 自动化测试框架的系列文章,一共分为 7 个部分 —— 在第 1 部分中,我们将介绍自动化测试的原理,以及我们做所有自动化测试的基础:状态模拟系统。
基于爱彼迎自身的技术框架和人员组织,Android 自动化测试一直是非常具有挑战性的。过去,我们依赖人工测试的方式在新版本发布前查找回归的问题。过去的这个方法让我们发现了一些潜在问题,尽管我们确实有数千个单元测试用例,但这些用例通常都是针对我们的基础框架和底层库的,而我们的 UI 功能却没法进行自动化测试。
我们曾经尝试过 Espresso,但是发现继续投入时间成本带来的边际收益很小。其中有几个方面的原因:
很难模拟网络请求。我们曾经尝试过使用 OkReplay 录制网络请求,但是发现维护最新的 JSON 网络请求记录的成本非常高;
我们的 Fragment 里既有 UI 代码又有业务逻辑代码,很难模拟或者隔离这两者;
Flakiness(指在同样的条件下,每次的测试结果存在不一致)使得测试结果不可靠,还降低了工程师的效率;
由于我们不断地迭代产品,维护的成本非常高,而且我们的工程师资源有限;
我们 App 的复杂性大多源自 UI 的设计,这是很难用 Espresso 来测试的。比如关于 UI 组件的边距、文本的颜色以及其他样式的断言,都是很难测试的。
由于以上这些原因,我们更倾向于通过 QA 团队的人工测试来查找 bug ,而不是花费工程师的时间去维护自动化测试。
这个方法曾经是奏效的,但是随着我们规模的持续增长,它开始暴露出问题。我们的 APP 变得越来越复杂,有几十个工程师在这上面贡献代码,有许多独立的团队,数百个界面,以及不断增加的业务需求。
这些问题使得在每次版本迭代中很难有效地发现回归的 bug。我们不计划再使用 Espresso,因为之前遇到的问题依然存在,并且会随着我们 APP 的增长而被放大。很显然,我们需要认真投入资源到自动化测试中,但是在这之前,我们需要先对我们的产品架构进行逐步的重构。
进入 MvRx 的时代
我们之前一直使用 Fragment 构建我们的 UI,并把网络请求、数据存储和 UI 管理都集中写在一个类里。这样做带来了不少问题,这些问题在 Android 社区里也非常热议。特别是对于自动化测试来说,这是很有问题的,因为我们并没有一个可靠的方法为每个 Fragment 的字段赋值,逐个模拟这些字段的值是很繁琐的。我们也没法避免或者检测到 Fragment 里的某些数据发生改变时对测试结果产生的影响。
这些问题导致了 MVP、MVI 及 MVVM 等架构的产生,这些架构能够做到 UI 与业务逻辑的分离。因为考虑到引入的成本,以及在数十个团队中强行统一标准的难度,我们最初并没有很快采用其中一种架构。但是当 Android Jetpack ViewModel 发布的时候,我们判定它很适合爱彼迎。
我们想要给产品开发团队提供一个标准的架构,因此我们在 Jetpack ViewModel 的基础上打造了一个框架,这就是我们在 2018 年发布的开源库 MvRx。
由于已经有很多关于 MvRx 的文章(包括 Gabriel Peal 所做的一些很棒的报告),有兴趣的朋友可以看一看。为了更好地理解这篇文章,我们先得理解 MvRx 依赖单一的 State 类这个原理,这个类包含了 MvRx 渲染一个界面所需要的所有数据。这个 State 类是用 Kotlin 的 Data class 实现的,以此保证它的实例是不可变的,因此 State 只能通过提交一个新的实例进行更新。MvRx 提供了一些 API 以便设置新的 State 以及监听 State 的变化。
MvRx 也能和 Epoxy 很好地集成,使开发者可以以编程的方式构建 UI,这正是我们在爱彼迎使用的模式。有了 Epoxy,我们的 MvRx Fragment 只需提供一个 Controller,这个 Controller 的输入是当前的 State,输出则是需要展示的 View 以及绑定到这些 View 的数据。
对于一个简单的界面,这就是我们需要写的所有代码:
data class MyState(val text: String = "Hello World") : MvRxState
class MyViewModel(state: MyState) : BaseMvRxViewModel<MyState>(state)
class MyFragment : MvRxFragment() {
val viewModel: MyViewModel by fragmentViewModel()
fun epoxyController() = simpleController(viewModel) { state ->
textRow {
id("title")
text(state.text)
}
}
}
用 MvRx 和 Epoxy 实现的 “Hello World” 例子
需要注意的是,MvRx 也可以和 Android Data Binding 等其他 UI 框架配合使用,它并不依赖 Epoxy。
对于自动化测试而言,很重要的一点是,我们可以任意设置我们想要显示在界面上的 State, 并且在一个隔离的条件下对它进行测试,而无需担心数据来自哪里或者它会不会受到其他因素的影响而改变。此外,我们可以确保 Fragment 的 UI 完全只是 State 的一个函数,而不会有一些不可控的因素造成测试结果的不稳定性。
每次 State 发生改变时,UI 都会完全基于最新的 State 被重新构建。借助 Epoxy 我们可以用最少量的样板代码高效地完成这些步骤。当然,你喜欢的其他任何 UI 框架都可以和 MvRx 配合使用,比如 Android Data Binding。
目前 MvRx 已经被我们所有的产品团队采用了,在爱彼迎这对于我们 是一个巨大的成功。有了 MvRx,我们可以在更短的时间内构建新的功能,同时使得这些功能更易于维护、更健壮、性能更好,当然,也更易于测试。
我们的自动化测试愿景
当爱彼迎的业务团队开始采用 MvRx 时,我们就开始研究怎样更好地对它做自动化测试支持。我们的工程师依旧可以为他们的产品功能写基于 Espresso 的测试用例,但是正如我们前面讨论过的那样,Espresso 的不稳定性可能会给持续集成带来问题。结果就是,工程师们需要花费大量的时间来维护他们的测试用例。
与之相反,我们希望构建一个自动化测试框架,它可以使得我们以最少的工作量达到很高的测试覆盖率。我们调研了诸多可选的方案,并提炼出针对我们的自动化测试框架的一些指导性原则。在理想情况下:
测试理应是稳定的,测试结果不应该存在不稳定性;
给一个界面添加自动化测试应该是很容易的,并且更新测试用例也是很简单的;
在持续集成中,自动化测试应该运行得很快;
自动化测试框架应可支持 100% 的代码覆盖率;
自动化测试应该支持对代码变更的快速验证,从而使得开发效率得到提高。
然而理想很丰满,现实很残酷,因为通常来说如果你不花费大量的时间来编写和维护测试用例,是很难获得一个很好的测试覆盖率的。然而,我们还是希望有一个比 Espresso 更优的解决方案,因此我们开始思考怎样才能达成我们的愿景。
MvRx 的状态模拟
我们最终敲定了一个 MvRx 模拟系统,Fragment 可以为它所使用的任意 ViewModel 定义对应的 State 的模拟实现。每个界面都有一个默认的 State,也可以有基于默认 State 的其他 State 变体,方便测试界面的其他状态。我们所有的自动化测试都是在这个模拟系统之上构建的。
这是我们 MvRx 范例 App 里使用模拟系统的一个例子。这个页面展示了一个笑话的列表,这个列表是以分页的形式异步加载的。
展示笑话列表的 MvRx 范例 App
我们直接在 Fragment 里重写 provideMocks 方法就可以注册这些模拟对象。
class DadJokeFragment : MvRxFragment() {
val viewModel: DadJokeViewModel by fragmentViewModel()
override fun provideMocks() = mockSingleViewModel(
viewModelReference = DadJokeFragment::viewModel,
defaultState = mockDadJokeState
)
}
这段代码声明强制了 ViewModel 使用哪个 State,并且如果 Fragment 需要参数,也可以在这里指定。至于 ViewModel 怎样生成这个 State 并不重要,不管它是通过网络请求、数据库查询或者其他的方式。通过我们的模拟框架,ViewModel 就被固定在一个无法改变的 State 上了。Fragment 接收到这个 State 并且渲染出始终一致的 UI 以便自动化测试。因为 MvRx 使用 Kotlin 的 Data class 来实现 State,模拟一个 Fragment 唯一要做的工作就是为它实现一个的完整的 State。
我们可以在另外一个单独的文件里定义这个模拟的 State,使它更易于管理。
val mockDadJokeState = DadJokeState(
jokes = Success(
value = JokesResponse(
nextPage = 3,
results = listOf(
Joke(
id = "0LuXvkq4Muc",
joke = "I'm tired of following my dreams. I'm just going to ask them where they are going and meet up with them later."
),
Joke(
id = "0ga2EdN7prc",
joke = "Did you hear about the guy whose whole left side was cut off? He's all right now."
),
Joke(
id = "0oO71TSv4Ed",
joke = "Why didn't the skeleton cross the road? Because he had no guts."
)
)
)
)
)
对于一些复杂的界面,这个 State 的模拟可能是一个巨大的 Data class,而且手动创建这个 Data class 是很繁琐的。因此,我们提供了一个便捷的方式去创建它:
在 Debug 包运行时,MvRx Fragment 会注册一个广播接收器用于监听特定的 Intent Action。
工程师在命令行运行一个 Kotlin 脚本,这个脚本会通过 adb 工具发送一个带有特定 Action 的广播。
当广播接收器接收到这个广播时,会通过反射的方式收集当前 Fragment 所有的 ViewModel。
然后通过反射的方式分析这些 ViewModel 的 State,从这些 State 对象里获取主构造函数的参数值并生成重新构造它们的代码,并且用递归的方式处理嵌套的对象,最终生成可以完整地构造出当前 State 对象的代码。
每个 ViewModel 对应的生成代码会以 Kotlin 文件的形式保存到设备里,然后脚本通过 adb 把生成的代码从手机里提取出来,并保存到 App 代码库里一个名为 mocks 的包里。
要用这个模拟的数据来做自动化测试,工程师只需要在手机上打开 APP 对应的界面并运行一行命令。这个模拟的数据就会被生成并且复制到代码源文件里,然后只要在 Fragment 上添加上述的几行代码就一切都完成了。即便是一些复杂的界面,也只需要花几分钟的时间,并且整个界面能够达到很高的测试覆盖率(接着往下读,我们会讲述利用这份模拟的数据具体运行了哪些方面的自动化测试)。
模拟数据的变体
因为我们使用不可变的 Kotlin data class 来实现 State,默认的模拟数据可以在共用同一个 ViewModel 的不同 Fragment 之间复用,这样可以节省在一个流程的多个 Fragment 上模拟数据所需的时间。
当 State 的数据发生改变的时候,它对应的模拟数据只需要在一个地方修改就可以更新整个流程的测试。我们也有编译时的检查,保证当 State 的数据结构发生改变时,模拟的数据也要对应更新。
如果共用一份模拟数据的多个 Fragment 需要不同的默认 State,它们可以利用 Data class 的 “copy” 构造函数。一个界面有多个 State 的变体做不同的测试,也是很常见的。这些 State 的变体也可以利用默认的 State 和 “copy” 构造函数。
为了精简修改默认 State 的流程,我们创建了一个 DSL(Domain-Specific Language,领域特定语言)以便定义 State 的变体。比如这个例子:
override fun provideMocks() = mockSingleViewModel(
viewModelReference = DadJokeFragment::viewModel,
defaultState = mockDadJokeState
) {
state("Loading") {
copy(jokes = Loading())
}
state("Empty results") {
copy(jokes = Success(jokes.copy(results = emptyList())))
}
}
使用这个 DSL 时,每个变体都有一个名称和对应的一个模拟 State 实例。上述代码创建了 3 个模拟数据:
一个名为 “Default state” 的主模拟数据,它使用在参数列表里提供的默认 State 实例;
一个名为 “Loading” 的变体,它对应的初始请求正在加载数据;
一个名为 “Empty results” 的变体,它对应的返回数据是空的。
这个 DSL 对于修改嵌套的数据尤为方便。一般情况下,使用 Data class 的 “copy” 构造函数修改一个嵌套了好几层的属性的值是很麻烦的。比如:
state("Empty results") {
// Nested copying - gross!
copy(request = Success(request.copy(results = emptyList())))
// A cleaner way to specify the same thing
set { ::request { success { ::results } } }.with { emptyList() }
}
这个新语法从默认的 State 开始,通过反射指定逐层递增的嵌套。最后指定的属性就是要被修改的属性。对应的属性嵌套得越深,这个方法的帮助就越大。
这个语法可以用来修改任意的数据,同时也有一些辅助函数可以让我们更方便地修改一些常见的原始类型数据。我们再看一下在一个订单页面里使用更加复杂的模拟 State 的例子:
state("Show cancellation policy subtitle") {
set { ::bookingDetails { success { ::cancellationSection { ::showSubtitle } } } }.with { true }
// Can also be written more simply as:
setTrue { ::bookingDetails { success { ::cancellationSection { ::showSubtitle } } } }
}
其他常见的值,比如空值、零值和 “false” 等,也有对应的快捷方式。
如果我们想要修改多个值,也可以使用链式方式调用这些方法来实现。但是我们建议每个模拟数据的变体只修改一个属性值,这样可以控制它对应的测试范围。
state("Not super hosted") {
setFalse { ::listingDetails { success { ::isHostedBySuperhost } } }
.setFalse { ::listingDetails { success { ::primaryHost { ::isSuperhost } } } }
}
Lambda 表达式的返回值作为这个变体最终的模拟数据。
如果 Fragment 有参数,参数也是可以修改的。这让你可以测试 Fragment 在不同的初始化参数下的用例。
args("No dates") {
setNull { ::checkInDate }
}
总的来说,这个语法使得测试边界情况的流程变得很简单。每个模拟数据变体对应一个不同的测试用例,使得每个界面都能完整地被测试所覆盖。
模拟启动器
开发一个功能的一般流程是,写代码 -> 运行 -> 检查运行结果是否符合预期,然后不断地重复这几个步骤。如果在完整的 APP 上开发,你通常需要重启整个 Activity,并且可能需要花些时间去点击让它跳转到一些嵌套的界面,之后才能看到你真正修改的地方,这个过程是很繁琐的。
为了减轻这方面的痛苦,我们构建了一个启动器,让开发者可以直接跳转到 APP 的任意界面,并且带着需要测试的 State。
MvRx 启动器主界面的例子
这个启动器界面是我们开发版 APP 的入口 Activity。这个 Activity 会自动检测出 APP 的 Dex 文件里所有的 Fragment,并且收集它们模拟的变体,然后在这个界面上列举出来。
一个 Fragment 在 MvRx 启动器上的模拟变体选择器
点击一个 Fragment,它所定义的所有模拟的变体都会展示出来,选择其中一个,它所对应的变体数据就会立即被加载出来。
这使得跳转到 APP 的任意界面(或者测试这个界面的边界情况)变得很简单。即便这个界面在正常情况下所在的路径很深,也依然能够被快速定位到。
从技术上看,这些开发版 APP 是通过 Android Flavor 实现的,它只包含工程师想要测试的功能模块。这些 Flavor 利用 App 的模块化架构来获得更快的编译速度。
为了进一步提升工程师迭代的速度,启动器会记住上一次选择的 Fragment 及其模拟数据的变体,然后在下一次启动的时候自动打开它。它也支持 Deep link,因此也可以从命令行直接打开任意的界面及其模拟的 State。模拟变体选择器让你可以很方便地测试界面的所有状态以及一些边界条件,使得你在提交一个变更之前可以很容易地在本地进行测试。
开源
这套模拟系统以及启动器旨在可以很干净地集成到 MvRx 的库中。我们一开始是在我们内部的 App 里构建这套系统,但从 MvRx 的 2.0.0 alpha 版开始,我们就把它移入到了 MvRx 的开源库中。
我们将会在这个开源项目上继续投入,以确保 MvRx 是一个用于构建 APP 的出色架构。
Next: 自动化截屏测试
在这篇文章中,我们介绍了在 MvRx 之上构建的模拟框架,它使爱彼迎的 Android APP 得到了全面的自动化测试。我们还展示了 MvRx 启动器,这是我们在模拟框架之上构建的辅助工程师测试代码的诸多工具之一。
在后续即将推送的第二部分文章中,我们将讨论如何利用这套模拟系统实现自动化的截屏测试,以及如何在持续集成中发现 UI 的回归性问题。
系列文章概览
本系列文章将系统性介绍爱彼迎 Android 的自动化测试,共包含 7 篇文章。我们将陆续为您推送。
第 1 部分 - 自动化测试的原理与状态模拟系统(本篇文章)
第 2 部分 - 利用 MvRx 和 Happo 做自动化截屏测试
第 3 部分 - 自动化交互测试
第 4 部分 - ViewModel 单元测试的框架
第 5 部分 - 爱彼迎自动化测试框架的架构
第 6 部分 - 如何保持模拟的一致性
第 7 部分 - 测试代码生成及持续集成的配置
引用
1. Espresso:https://developer.android.com/training/testing/espresso
2. 爱彼迎开源库 OkReplay:https://github.com/airbnb/okreplay
3. 爱彼迎开源库 MvRx 介绍:https://medium.com/airbnb-engineering/introducing-mvrx-android-on-autopilot-552bca86bd0a
4. 爱彼迎开源库 Android Epoxy:https://github.com/airbnb/epoxy
5. Gabriel Peal 关于 MvRx 的报告:https://www.youtube.com/watch?v=Web4xPi2Ga4
原文作者:Eli Hart,译者:Tiga Liang,校对:Ted Chan,Betty Xi。
感谢阅读!如果你想了解关于爱彼迎技术的更多进展,可以在 Github 上关注相关项目,或在 Airbnb.io 上查看相关文档和样例。
如果你想了解关于爱彼迎技术团队的更多进展,欢迎关注我们的 Github 账号以及爱彼迎技术团队微信公众号。