本篇是 Airbnb 爱彼迎 Android 自动化测试系列文章的第 4 部分,我们将介绍一套用于自动化测试 ViewModel 的框架。
在第三篇文章中,我们介绍了自动化交互测试是如何通过记录状态变化的方式测试 ViewModel 的部分代码。然而,这种方式无法测试到代码逻辑的所有边界情况。ViewModel 对于控制每个页面的正确行为起着至关重要的作用,因此我们需要对它进行更深层的测试。
ViewModel 的自动化测试
ViewModel 的自动化测试包含一部分手写测试用例,为了尽可能减少手动编写测试代码的工作量,我们为开发者提供了一套自动化测试框架。这个框架包含用于简化测试声明的 DSL(领域特定语言),与我们的网络堆栈集成以对执行的请求进行断言,同时与我们的状态模拟系统配合从而轻松设置 ViewModel 状态以进行测试。
我们的单元测试框架基于以下几个核心准则:
一个 ViewModel 的函数应该是可独立测试的,ViewModel 的设计不应依赖于各个函数调用之间的交互
当函数被调用时,它的行为应当完全由 ViewModel 的状态和传入参数决定
函数的输出应当是给 ViewModel 设置的一个新的状态或者是一个对依赖的调用
基于以上准则,框架的实现函数如下:
每个单元测试调用一个单独的 ViewModel 函数
单元测试的输入为 ViewModel 的初始状态 + 传入待测函数的参数
单元测试的输出为一个对于状态改变的断言(assertion)和 / 或对于预期调用依赖的校验(通过 Mockito 实现)
举个小例子
让我们来看一个存储了可更新字符串的 ViewModel:
data class TextState(val text: String? = null) : MvRxState
class TextViewModel(state: TextState) : MvRxViewModel<TextState>(state) {
fun setText(text: String) {
setState {
copy(text = text)
}
}
}
测试 setText 函数的代码:
fun setText() = TextViewModel::setText {
withParams("hello") expectState {
copy(text = "hello")
}
}
这段代码指定了对待测函数的引用、调用函数的参数以及期望的状态变化结果。这里我们测试调用 setText(“hello”) 使得文本状态更新为“hello”这一功能。
expectState 函数接收初始 State 并返回预期的输出 State。这个返回的 State 必须与输出完全匹配,否则测试失败,框架将打印出哪些值不相等。实际上,expectState 定义了哪些值将要更新、新值应该是什么,以防遗漏。
测试设置
测试框架负责初始化 ViewModel、收集测试声明和检查断言。
测试使用常见的 JUnit 和 Robolectric 设置,每个测试类对应一个 ViewModel。测试类实现了一个接口,测试框架使用该接口为每个测试初始化一个新的 ViewModel。
例如,上述 ViewModel 的完整测试类形如:
class TextViewModelTest : ViewModelTest<TextState, TextViewModel> {
override fun buildViewModel() = TextViewModel(TextState())
fun setText() = TextViewModel::setText {
withParams("hello") expectState {
copy(text = "hello")
}
}
}
测试框架使用 buildViewModel() 函数为每个测试创建一个新的 ViewModel。
ViewModel 的初始状态可以是从现有的屏幕模拟重用的模拟状态。这允许屏幕截图测试、交互测试和 ViewModel 单元测试共享相同的底层模拟实例。大大减少了测试设置的工作量,而且如果 State 数据结构发生变化,模拟状态只需要在一个地方更新。
修改状态
如果测试需要使用默认状态修改以后的版本,可以使用我们之前提到的数据类 DSL 轻松更改嵌套状态。
为了演示,让我们将上面的示例扩展得更复杂一些,给它增加一些可以判断文本是否加粗(bold)的状态。
data class TextOptions(val bold: Boolean = false)
data class TextState(val text: String? = null, val options: TextOptions = TextOptions()) : MvRxState
class TextViewModel(state: TextState) : MvRxViewModel<TextState>(state) {
fun setText(text: String) {
setState {
copy(text = text)
}
}
fun setBold(bold: Boolean) {
setState {
copy(options = options.copy(bold = bold)
}
}
}
按照测试语法,检查 setBold 函数的测试语法如下所示:
fun setBold() = TextViewModel::setBold {
initialState {
setFalse { ::options { ::bold } }
}
withParams(true) expectState {
setTrue { ::options { ::bold } }
}
}
上面的测试代码将执行以下操作:
将嵌套的布尔属性 bold 初始化为 false
调用 setBold 函数,传入参数值为 true
验证 ViewModel 的最终状态中 bold 参数已成功设置为 true
可扩展性
DSL 使用可插拔(pluggable)系统,以便第三方扩展功能添加自定义声明和断言。我们可以使用它来检查网络请求是否按照预期被正常发出。
在示例 ViewModel 中,让我们添加一个从网络请求返回值中加载文本的函数。
fun loadText(textId: Long) {
buildRequest<String>(
path = "text/endpoint",
params = {
kv("id", textId)
}
).execute {
copy(text = it)
}
}
测试此功能的函数如下所示:
fun loadText() = TextViewModel::loadText {
withParams(1)
expectRequests {
GET("text/endpoint?id=1") shouldReturn "server result"
} expectState {
copy(text = "server result")
}
}
本次测试:
调用 loadText 函数,传入参数id = 1
断言我们希望 ViewModel 以 id 作为查询参数对给定的 API 路径发送 GET 请求
指定模拟返回值为“server result”
断言文本的最终状态值与我们模拟的返回值“server result”匹配
这允许我们用网络层测试我们的 ViewModel,轻松检查是否发出了所需的请求并模拟返回值。
expectRequests 函数是单元测试框架的扩展函数。这使我们既能够开源核心库,又可在爱彼迎测试我们的内部库。
同样值得注意的是,在单元测试和集成测试中,我们从未在 JSON 层模拟网络请求,因为维护 JSON 文件的模拟十分困难且没有必要。相反,我们采用 GraphQL 为每个网络请求模式提供编译时间保证。这意味着我们只需要断言 ViewModel 进行了正确的查询,并且我们可以相信返回值将遵循有效的、符合预期的格式。
这简化了我们的测试范围,提高了可维护性,并为我们网络层的功能提供了保证。
高级用法
单元测试框架也提供了其他不错的实用程序来测试常见的 ViewModel 模式。
自动验证
一种常见的情况是 ViewModel 函数更新 State 上的单个属性,例如我们上面的示例切换“bold”布尔值。该框架为这种情况提供了特殊处理,使其只需几行即可测试。
fun setBold() = TextViewModel::setBold {
sets { ::options { ::bold } }
}
使用此语法,本测试:
表示正在测试 setBold 函数
指定将被更改的嵌套状态属性的引用
检测 setBold 函数的参数类型(本例中为布尔型)
根据参数类型生成测试输入。对于布尔值是 true 和 false。如果它是可空变量(nullable),它还将测试“空(null)”输入。
使用生成的输入值调用 setBold 函数,并在每次调用后检查状态中的“bold”属性是否已更新为相同的值。
这适用于任何具有单一原始类型的函数 —— 向待测函数传入不同测试值,通过反射检测类型,然后检查状态上的属性值以确保它和预期的测试值相等。
更通用地,我们还支持多参数函数以及状态属性类型与函数参数类型不同的情况。
例如,下面测试一个函数,它对输入进行平方运算并将运算结果设置为状态“result”属性的值。
fun squared() = TestViewModel::squareNumber {
setsMapped(2 to 4, 5 to 25) { ::result }
}
我们可以容易地列出输入到输出的映射;它会自动调用函数传入每个输入参数,并检查对应的输出状态。
测试初始化
ViewModel 在初始化期间(当一个新实例被实例化并调用构造函数时)执行网络请求或其他任务也很常见。
例如,假设我们把上面的 TextViewModel 改为在创建时从网络请求加载文本。我们可以使用此语法测试这一行为。
fun initialization() = testInitialization(
expectRequests = {
GET("text/endpoint")
},
expectState = {
copy(text = Loading())
}
)
我们断言当 ViewModel 被实例化时:
向预期的 API 路径发出网络 GET 请求
将 text 属性设置为“Loading”状态
与正常的函数测试语法相比,此语法是必需的,因为它必须包装 ViewModel 的实例并隔离那里的行为。另一方面,在测试函数时,我们会在实例化期间排除被隔离的行为,以免将它们混淆。
生成测试脚手架
最后,在多模块世界中,为每个新创建的模块设置单元测试环境可能很乏味(我们有数百个模块!)。对于每个模块,我们需要:
一个 Robolectric 测试运行器
用于扩展的测试类的基础测试,以便应用 runner
支持 dagger 测试覆盖的脚手架(一个新的 Dagger 模块加上一个用来设置 dagger 模块注入的 Test 应用程序)。
一个支持模拟最终类和函数的 mockito 插件(用于 Kotlin)
我们已经创建了自动为模块生成测试脚手架的工具,因此开发人员可以立即开始编写单元测试,无需处理任何繁琐的配置。
此外,我们还创建了一个 Intellij IDEA 插件,可以为我们生成新的 MvRx ViewModel。这允许我们为我们添加的每个新 ViewModel 自动创建一个测试文件。
下一篇:我们的自动化基础设施
总的来说,我们构建单元测试框架的目标是:
让 ViewModel 的逻辑测试更容易;
同时,提供一个简单而灵活的 API,可以涵盖 ViewModel 的所有用例。
此外,我们将库设计为可扩展的,以便将它开源,允许各个团队轻松地将自己的断言添加到 DSL。
虽然这对我们来说很棒,对于全面测试业务逻辑也是必要的,但最好的测试应该是我们可以自动生成的!接下来,在本系列的第 5 部分,我们将重新审视我们的自动化集成测试框架,看看它如何为我们的交互和屏幕截图测试提供支持。
系列文章概览
本系列文章将系统性介绍爱彼迎 Android 的自动化测试,共包含 7 篇文章。我们将陆续为您推送。
第 1 部分 - 自动化测试的原理与状态模拟系统
第 2 部分 - 利用 MvRx 和 Happo 做自动化截屏测试
第 3 部分 - 自动化交互测试
第 4 部分 - ViewModel 单元测试的框架
第 5 部分 - 爱彼迎自动化测试框架的架构
第 6 部分 - 如何保持模拟的一致性
第 7 部分 - 测试代码生成及持续集成的配置
原文作者:Eli Hart,译者:Dan Zhao,校对:Yifan Zhu,Betty Xi。
特别感谢所有项目的参与者,以及相关开源库的贡献者。
感谢阅读!如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)。