本篇是 Airbnb 爱彼迎的 Android 自动化测试系列文章的第 3 部分中,我们将探索一个为测试用户操作而构建的自动化系统。
交互测试
我们在本系列文章的第二部分中介绍了截屏测试的实现方式。作为集成测试的第一部分,截屏测试填补了我们测试策略中的一部分空白,并能有效地捕捉回归 Bug,提升开发体验。这虽然覆盖了很大一部分 UI 代码,但是对于与用户交互相关的代码,比如点击事件的处理,并没有覆盖到。
这部分“处理交互的”代码可能包含复杂的逻辑,是 Bug 的常见来源。并且它在产品功能的代码中占很大比例,因此对于提升代码测试覆盖率来说,这一部分也非常重要。
用 Espresso 来测试交互代码是非常直接的——点击事件可以手动强制执行,并在结果中添加断言。不过由于种种原因,这些测试存在一些缺点:
View 需要通过 Id 或实际位置来定位,但他们可能会随着产品迭代发生变化
对于列表中的 View 必须先滑到对应位置
必须等待异步操作的结果,这通常是不稳定的,或者需要额外的代码才能正确处理
即使这些问题得到了解决,为页面上所有可能的交互手动编写测试也非常枯燥且繁琐,而且有些细节可能测不到,比如传递的参数或网络请求。
就像截屏测试自动检测 UI 变化一样,我们建立了一个类似的系统来监测交互处理的变化。然后我们利用 Approval Testing 技术来自动更新我们的测试。这使我们能够自动验证每个页面的行为,而无需编写任何传统的 Espresso 测试。
这背后的理念基于以下几点:
所有由点击产生的变化都是可观测的,并且可以用文本来表示
Activity 中的所有 View 的点击事件都可以由代码来执行,并对结果进行观测,这样我们能生成一份报告将 View 及其点击行为对应起来
我们可以独立地测试一个页面,并把影响到其他页面的一些操作定义成接口,比如打开新的页面或返回结果
我们并不需要真的测试页面之间的交互,只要我们能够正确的测试对于输入(通过模拟状态和参数)的处理并验证输出(会影响其他页面的 Action)的结果是正确的
我们的实现方式如下:
首先确保被测试的页面完成基于模拟状态的构建,并等待页面的 View 布局稳定下来。
遍历 Fragment 中的每一个 View 并执行点击操作
点击后记录下产生的 Action,并阻止这个事件接下来的逻辑
将 View 点击后生成的结果输出到一个 JSON 文件中
和截屏测试一样,我们通过对比 JSON 文件的变化来监测交互结果是否有改变
这种实现方式的效果出奇的好,并且和截屏测试有很多相似之处,我们可以复用截屏测试中的大部分基础实现。接下来让我们看一下实现细节。
View 布局
在这一部分我们复用了截屏测试的代码。我们有一个基础的 Activity 接收一系列需要测试的模拟状态,对每一个模拟状态进行遍历展示,并在其布局稳定后执行测试代码。
基础的 Activity 的子类会执行对应的测试,比如截图或执行点击事件。
遍历 View 层级
页面中的 View 必须被依次处理。为此我们在 View 的树状结构上执行了深度优先遍历,拿到 View 并执行操作。我们会检查所有的 View 是否为可以点击或者长按点击的。在这个过程中我们遇到了两个问题。
首先,要支持 RecyclerView 意味着我们需要用代码去滚动 RecyclerView 来确保其中的每一个View 都能被测到。这就要求在深度优先遍历之前我们要先等这些新的 View 完成布局。
其次,点击事件可能会改变 View 的层级结构,所以我们不能立刻去测试下一个 View。例如点击事件可能会触发 Fragment Transaction,展示一个 Dialog 或者展开某个 ViewGroup。我们需要做的是把当前页面的 View 状态重置为初始状态,然后在之前的基础上继续遍历。
在每次点击事件后准确地重置当前页面的 View 状态对于测试的可靠性来说是至关重要的。这是我们能测试后续 View 的前提。
为了实现重置,我们会在一个独立的 Activity 中来执行测试。在每一次点击之后会把所有的 Fragment 都移除掉,然后再添加一个新的模拟 Fragment 回来。
如果点击事件是去展示一个 AlertDialog,我们会从代码里边关掉它。这里没有特别好的实现方式,我们是通过反射获取全局的 WindowManager 来实现的.
在执行点击事件之前我们会存储页面中当前遍历 View 的位置,页面重置后会从最后一个位置继续测试,这个遍历路径就是层级中 ViewGroup 子 View 的索引列表。
记录交互
每次点击后,测试框架都会把对应的结果记录下来。一般来说会产生两类结果:
Android 框架层的结果,比如 Fragment Transaction 或 Activity 的打开/关闭
Airbnb App 层的事件,比如对于 MvRx 状态的修改或执行网络请求
理想情况下,测试框架可以自动记录关于Andorid 框架层的事件,但是我们也需要一个干净的方式来监测事件对于内部系统产生的改变。
为了监测 Fragment Transaction, 我们在测试 Activity 中注册了一个 FragmentLifecycleCallbacks,并递归地检查 Fragment 栈中的变化。它记录了 Fragment 栈中完成后的状态以及每个Fragment 包含的参数,因此我们就有一组关于 Fragment 及其启动参数的记录。
最后,我们利用反射获取 WindowManagerGlobal 来检查点击后添加的 Window。如果添加的是AlertDialog 或 BottomSheetDialog 我们就可以获取其标题,内容以及按钮文字。我们同样也会强制关Dialog 防止它们干扰测试流程。
监测自定义系统的变化
理想状况下我们的交互测试框架也可以捕捉自定义系统的变化,比如网络层,数据库层,或日志层。不过我们会避免把测试代码放入生产系统,那样做的话过于简单粗暴。
我们会通过定义 Interface 和依赖注入(Dependency Injection)来解耦交互测试和实际的系统。实现方式如下:
创建一个 Interface 知道如何向 Test Runner 报告 Action
使用 Test Dagger Module 重写每个依赖的创建,并让其响应交互事件对 Interface 的调用
使用 Dagger 的 Multi binding 来把这些 Interface 收集到一个集合里边,最后再将其注入 Test Runner
一个经过慎重考虑的 Dependency Injection Graph,再结合 Multi binding,对我们的工作是至关重要的。这些都设置好后就会成为一个非常强大的框架,因为我们将可以捕获 App 中每次点击与我们的 Service 的交互变化。
捕捉非视觉的 View 数据
除了记录点击事件之外,我们的系统还可以测试 View 的非视觉行为。他们是在截屏测试中不能被捕获的,比如:
通过 View 的 “contentDescription” 来检查无障碍配置
WebView 或 ImageView 中加载的 Url
VideoView 的配置
为此,遍历过程中会把每个 View 回调回来,这样我们就可以检查它的类型,并在报告中添加任意我们想要的信息。这使得我们的框架可以扩展到任何自定义 View 或捕捉我们想要的关于 View 的信息。
监控交互何时结束
View 被点击后可能会触发异步操作,比如 Fragment Transaction、View 重绘(Invalidation)或者数据处理。我们不应该让这些异步操作影响到后续测试的稳定性,所以我们要么阻止这些事件(如果可以的话),要么等它们执行完成再重置 View 进行下次点击。
空闲状态的检测会在第五部分详细讨论。
输出 JSON 报告
一旦所有的 View 被点击并且结果被记录后,数据会被收集到报告里边。报告可以用不同的格式来展示,这一部分是主观的。我们会用下面这种格式来展示:
这个 JSON 对象展示的是页面上单个 View 的行为。完整的报告中每个可点击的 View 都会生成这样一个 JSON 对象。
JSON 对象最外层的 Key 表明了该 View 所处的层级结构。我们将其父 View 的 Id 连成一条链,这样屏幕上的 View 就能被一一识别了。
我们还注意到这是一个 RecyclerView 中的 TextView。它位于代表 View 的 Epoxy Model 中,即AccountDocumentMarqueeModel,这些细节可以让开发者更容易的弄清楚这个 JSON 在页面中对应的是什么。
接下来报告会描述的是点击后会发生什么。
这表示我们从 MvRxActivity 中打开了 UserProfileFragment,同时表明了被传入的参数以及 Request code。
经过一系列实验和试错我们最终采用了 JSON 格式,并总结出以下几个要点:
可读性
报告应该清晰地描述出 View 被点击后的动作。谨慎地选择 Key 的名字,使其尽量直观。
虽然报告可以包含 Metadata 来帮助用户更容易地看到哪个 View 受到了影响,但反过来看这也可能影响一致性,所以应该减少此类数据。
假如 Metadata 中包含 RecyclerView Item 的 Index(以便更容易发现哪个 Item 发生了改变),之后如果新的 Item 被添加进来,那其他所有的 Item 的 Index 都会改变,这样在报告中就会产生很大的变化,也不能同时满足可读性和一致性的目标。
虽然可读性很重要,但不应该以牺牲差异的可识别性(Diff-ability)或一致性为代价。只有当 View 的行为确实发生变化后才需要在报告中展示其改变。否则报告就会频繁地发生变化,不易阅读。
差异的可识别性
我们需要对报告进行比较,并使产生的变化容易地被识别出来。我们使用 JSON 是因为已经有很好的工具来做 Diff 了,阅读性也很好,很容易将 Key/Value 关联起来。
每个 View 都有一个标识符是非常重要的,这个标识符在 View 层级中是唯一的,在不同的代码分支中也是稳定的。这个标识符是与这个 View 的改变所关联的 Key,如果这个 Key 改变了,产生的 Diff 就会让人困惑。我们使用一个标识符来代表 ViewGroup 层级中的 View。这里尽量避免使用 Index,每当有其他 View 加进来 Index 就会发生变化,所以我们使用 View 的 Id 替代它。
如果 Diff 显示有变化,那么对于工程师来说该 Diff 应该是容易阅读,且差异是很容易识别的。如果读起来很困难,当有回归(Regression) Bug 的时候很容易被忽略掉。
一致性
不同的代码分支生成的报告应该是一致的,变化只应该发生在当 View 的点击行为改变时。如果一个 PR 生成了交互的 Diff 但实际上没有行为上的改变,那么大家就会忽视这些报告,从而导致一些真正存在的回归 Bug 也被忽略掉。
JSON Diff 比截屏测试的 Diff 更难读,毕竟截屏是可以直观看到的,JSON Diff 需要一些学习与理解才能知道发生了什么变化(这也是为什么报告应该有很好的差异可识别性)。
由于这些原因,一致性是非常重要的,我们已经做出了一些设计上的决策来优化它。比如 JSON 对象的Key 是排好序的,以避免因动作顺序产生的无效变化。
我们遇到过一个一致性的问题是,包含文本的数据(比如 Bundle 或 Intent)在多次运行中并不一致。
有两个主要的原因:
Class 没有实现 “toString()” 方法,而是用的默认实现即返回它的 Hashcode,比如 Person@372c7c43。为了解决这个问题,我们会使用反射,并根据其属性递归地生成一个一致的字符串。当我们在其原始的 “toString()” 方法中拿到的是 Hashcode 格式的值,或者该对象是一个 Kolitn 的 Data class 时我们就会这样做。
如果一个对象是一个整型(Integer),它可能是 Android 的资源值。在一次 Build 中他们是常量,但随着其他资源的添加和删除,代表同一资源的整型值在不同 Build 中会发生变化。为了使其稳定下来和(1)中一样,我们用反射来生成代表它的 String,先从资源数据中找到该整型值,如果在其中有对应的资源名字(比如 R.string.title_text)我们就会使用这个资源名字来代替整型值。
由于第 2 点,Kotlin 的 Data class 会使用一个定制化的 String 来代表,这种 Data class 会包含资源的声明并作为参数被传递。另外一般 Data class的 “toString()” 方法是生成好的不太会被自定义所以我们对其更改也是比较安全的。
不同类型 Action 的展示方式
上面例子中的 JSON 报告展示的是启动一个Activity,包含参数和一些 Flag。报告中也可以包含我们想要的其他数据类型,只要我们能在代码中定义它们。下面是我们系统中的一些例子。
关闭 Activity
当点击事件关闭一个 Activity 时,我们就会捕捉记录相关的返回信息。
记录日志
我们的内部日志系统采用了基于 Schema 的方式。我们会记录哪个 Schema 被 Log 了,以及相关次数。
View 的属性
之前提到关于 View 的任意属性也都可以被记录。这里记录了点击 View 时的的非视觉信息,比如 “content description”。
还有其他的,比如图片的 URL。
Toolbar Option 的点击
Toolbar 中的 Option 也全部会被点击。我们会记录 Option 的 Name 和 Id,以及点击后产生的 Action。
Fragment 切换
所有 Fragment 的参数和生命周期状态都会记录在报告中,所以我们也可以监测 Fragment 的跳转变化。
网络请求
点击后的网络请求也会被记录。包括其请求类型、参数、Header 和 Body。
ViewModel 的 State 更新
我们也会检测每一个 ViewModel 的 State 的变化,并且每个 Property 的改变和新的值都会被记录下来。
甚至还可以展示出对于 List 和 Map 的添加和删除操作。
总的来说,基于 JSON 的这套系统可以记录任何我们想要的信息,这使我们的测试更加全面。如果基于 Espresso 来写测试的话,工作将会变得非常繁琐且难以实现。现在我们所有的数据都是自动生成的,在审查的过程中可以直观地看到改变,并且可以一键 Approve 来更新数据。
找出报告中变化的内容
报告生成后我们如何跟踪发生的改变呢?这里使用了和截屏测试几乎一样的系统,并且 Happo 支持 JSON Diff。这就意味着我们可以使用已有的 Happo 库来生成 JSON 快照,上传到 AWS,生成 Happo 报告。
我们会基于当前代码分支生成一份报告,包含 JSON 快照和截屏测试。Happo 的 Web 页面会展示 JSON 和 UI 上的变化。生成的 JSON Diff 还可以利用 Happo 提供的现有工具,比如改变订阅和查看组件历史信息。
在下面的例子中,我们有一个 PR 改变了 ToggleActionRow 的行为,点击后会发出基于 GraphQL 的 ListingsQuery 请求。在这里我们就可以自动地捕捉行为的改变并清晰地展示出来。
可交互的 JSON Diff 展示了 Click 后的网络请求
此外,我们并不需要改变我们的 CI 设置,因为这些也只是添加到现有测试基础中的额外的 JUnit Test。生成的 JSON Diff 会被添加到已有的 Happo 截屏测试报告中。之后我们会有一系列的文章来介绍我们的 CI 设置,以及它是如何做到容易进行扩展的。
展望未来
这种交互测试方法虽然只运行了几个月,但目前收效良好,对于自动生成集成测试来说是很不错的方案。基于 Approve 的方法最大限度减少了创建和更新 Test 的工作,并且比手动编写的测试更详尽。
我们目前关注的只是捕捉常见的动作,还没有完全捕捉页面上所有可能的交互。还可以从以下几个方面继续提测试覆盖率:
找到 View 层级中的 EditText,用代码改变其内容并观察结果
捕捉 “onActivityResult” 回调的行为
记录 Fragment 创建和销毁时的情况(比如网络请求或日志)并将其纳入最终报告
下一篇:测试 ViewModel 内的逻辑
本篇文章介绍了自动化交互测试的解决方案。这些测试会捕捉 ViewModel 点击后的行为,比如点击导致 State 改变或执行网络请求,但并没有全面地测试到 ViewModel 中的所有边界 case。
第四篇将会介绍我们如何使用单元测试来手动测试 ViewModel 中的所有代码逻辑,还会讲到为了让这个过程变得更简单所创建的 DSL 和框架。
系列文章概览
本系列文章将系统性介绍爱彼迎 Android 的自动化测试,共包含 7 篇文章。我们将陆续为您推送。
第 1 部分 - 自动化测试的原理与状态模拟系统
第 2 部分 - 利用 MvRx 和 Happo 做自动化截屏测试
第 3 部分 - 自动化交互测试
第 4 部分 - ViewModel 单元测试的框架
第 5 部分 - 爱彼迎自动化测试框架的架构
第 6 部分 - 如何保持模拟的一致性
第 7 部分 - 测试代码生成及持续集成的配置
原文作者:Eli Hart,译者:Ling Wang,校对:Ted Chan,Betty Xi。
特别感谢所有项目的参与者,以及相关开源库的贡献者。
感谢阅读!如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)。