如何自动化测试 React Native 项目 (上篇) - 核心思想与E2E自动化

原创发布于 tech.glowing.com

React Native (RN) 是 Facebook 开源的跨平台应用开发框架,由于 RN 提供的高效直观的跨平台开发模式和不错的性能,我们在开发 Glow 的中文 App - 共乐孕的时候选择了以 RN 为主要框架进行开发。

随着开发模式的逐渐成熟,对RN项目的自动化测试也在不断探索中慢慢完善, 最终选择了 Detox (by Wix) 做 E2E 自动化测试, Jest (FaceBook) + Enzyme (Airbnb) 做集成测试和单元测试。

在这篇文章中我会介绍一下我对 React Native 项目自动化测试的核心想法以及自动化测试中 E2E 部分的具体实现。在 如何自动化测试 React Native 项目 (下篇) 中会详细介绍单元测试的具体实现方法。

核心思想

先介绍一下对自动化测试的思考和对E2E,单元测试, 集成测试的优缺点以及重要性的想法:

自动化测试

自动化测试的重要性相信做过一段测试工作的人都有所了解, 简单来说就是随着 App feature 的不断增长和支持的平台、OS的增加, 测试 case 的数量会成倍的增长。
假设 App 有3个 feature 的时候, 测试用例有15个; 等App增长到有10个 feature 的时候,测试用例可能就增长到了 ~50 个。这样每个版本开发的时候开发人员花在 feature 上的时间精力不会增加太多, 但对测试来说做回归测试的压力就陡然增加。

如果没有一套完整可靠的自动化测试, Team 可能只有两个选择 - 招更多的手工测试QA或者放弃掉一些回归测试 case 来保证 QA 能按时完成测试任务。无论哪种都是不scalable的方案。
自动化测试的重要性在这个时候就体现出来了:

  • 自动化测试可以提供高效的, 并且可重复的测试方法(重复劳动是人最不擅长的

  • 可以提高 Engineer team 的开发速度

  • 长久来看是比人工测试更可拓展, 可维护的。

测试金字塔

Test pyramid

测试金字塔 是目前比较流行的一种设计自动化测试的思路,核心观点如下:

  1. 越下层的测试效率越高, 覆盖率也越高, 开发维护成本越低

  2. 更上层的测试集成性更好, 但维护成本更高

  3. 大量的Unit测试, 少量的集成测试和更少的E2E测试是比较合理的平衡点(Google在blog中推荐70/20/10的测试用例个数比例)

简单介绍一下对 Unit, Integration 以及 E2E 自动化测试的想法:

E2E 测试

E2E自动化测指通过UI来从头到尾(End-To-End)的测试 App 的工作流程是不是符合预期。
E2E的优点是可以模拟用户的真实scenario,代替手工测试来测试完整的集成系统。在任何自动化测试体系中,E2E都是最接近真实用户的,因此是最让人有信心的测试方法。

但实际应用中E2E测试的缺点也很明显:

  • 要花很长时间才能找到真正的bug。 在fail的E2E case里找root cause很痛苦。

  • E2E测试依赖于测试Build和测试环境。 经常E2E case挂了是因为各种非bug的原因,需要花时间和精力去维护测试Build和环境才能保证E2E case都pass。

  • 小的bugs很难被发现。 E2E case的assertion经常忽略掉不会影响整个Flow的bug, 但这些bug是不可接受的。

  • 不稳定性高。 经常测试脚本因为一些意外的情况fail(比如网络慢, 测试机慢, 意外的弹出框 等等)。

  • 高维护成本。 当UI或者功能变化的时候, 维护E2E测试的成本是很高的,如果E2E带来的收益还比不上维护他们的成本, 就得不偿失了。

因此全部用E2E进行自动化测试是不现实的。 我个人之前也试过写150+条E2E脚本来进行测试, 后来维护脚本的时间精力实在太大。因此我们需要更高效和容易维护的测试脚本来代替E2E测试。

单元测试

单元测试通常指保证code中的一个单元正确工作的测试。 一个单元可以指一个方法, 一个class,甚至一个component; 可以按照code的结构进行划分。

单元测试的优点如下:

  • 快速! 运行unit test和E2E相比是非常快的, 特别是mock了一些被测unit不关心的外部模块的时候, 比如network request, db request等等.

  • 可靠, 稳定。 不会因为一些外部原因意外的挂掉。

  • 独立。当测试挂掉的时候可以很快的找到Bug的root cause。

单元测试的缺点在于无法保证每个单元都正确, 当他们都组装在一起的时候也是正确的。

单元测试 vs. E2E测试

以上两种测试方法各有各的好处,我们应该选择利用两者的优点,并且让两种测试方法的缺点带来的风险更小。
这也符合前面测试金字塔中讲过的观点 - 用大量的单元测试来保证每个单元都是正确工作的, 同时用少量的更高层测试来保证集成起来也是正确工作的。

在维护自动化测试时,我的经验是:

  • 当E2E测试暴露出一个bug的时候, 尽量用最底层的单元测试来重现这个bug, 然后添加一个单元测试来保证这个bug不会出现。

  • 如果单元测试无法重现这个bug, 再用更上层的集成测试或最高层的E2E测试来保证这个bug不会出现。

  • 在测试金字塔中, 把自动化测试脚本尽量的‘推’到下层。

  • 应该尽量避免重复的测试, 即能用单元测试覆盖的测试不要用集成测试或者E2E测试再实现一遍。

集成测试

之前讲过单元测试的风险在于每个单元分别都是正确工作的不等于放在一起也是正确工作的。
这时候除了用E2E测试来做集成, 还可以用把几个单元组装在一起的集成测试的方法来减少这种风险。

集成测试的好处:

  • 可以测试和其他service的集成, 比如db/网络请求等等

  • 保证几个单元组装在一起的时候是正确工作的

  • 比E2E测试更小, 更好维护, 更集中在测试逻辑中, 同时减少单元测试的风险

Example

以上图举个例子:
比如 Module A 有5个Button A-E, 分别对应 Module A 的输出1,2,3,4,5。
Module B也有5个Button A-E, 分别代表对 Module B 的输入+1, +2, +3, +4, +5后输出。
现在对这个系统设计测试用例:

方案1: 从黑盒的角度看, 如果把 Module A 和 B 当做一个整体, 那么一共需要 5*5=25个测试用例去测。对A的5个button的每个选择, B也有5个选择可以选。

方案2: 从单元测试(白盒)的角度去看, Module A 和 B 分别需要5个单元测试来保证自己是正确工作的。
此外还应该有1条集成测试 case , 来保证Module A和B之前的数据交互是没问题的(避免万一数据从A到B之前发生变化或者type不一致)。
这条集成测试可以选择Module A和B中的任意一种选择, 只要保证他们之间的集成的正确性即可。

从这个例子可以看出单元测试的高效性, 因为独立看每个单元只要负责自己module的逻辑正确性, 不依赖于module的输入是什么。
同时集成测试 case 保证了两个module组装在一起的时候也是正确工作的。 方案2一共有11个case,对比方案1的25个case效率就高了许多。
而且在未来的拓展中, 比如Module A添加了第6个选项(输出6), 方案1就需要添加5个case, 但方案2依旧只需要添加1个case, 因为对 Module A 的单元来说只多了1条逻辑。

 

React Native 自动化测试的具体实现

我会在后文中具体介绍在 Glow 我们选择用来实现这套自动化测试系统的框架以及详细的实现方法。

  • 在E2E测试中我们选择了 Wix 公司开源的 Detox 框架,相比传统的测试框架Detox灰盒测试的方法在RN里面有最好的稳定性。

  • 集成测试和单元测试选择了 Jest 和 Enzyme (参考 下篇 )。 得益于 React Native 优秀的可测性和React良好生态环境, 集成/单元测试都可以用很直观简单的方式实现。

E2E自动化测试 - Detox

Detox是Wix公司开源的一款灰盒自动化测试框架。底层使用了Google开源的 Earl Grey(iOS)和 Espresso(Android)。

在详细介绍Detox之前先简单介绍下传统黑盒自动化测试框架的特点和问题:

  • 传统的黑盒测试框架的工作方式通常为根据 id 或者 text 等条件在 view hierarchy 中找目标元素,如果找不到就用sleep或者循环重试直到达到 timeout。 找到这个元素之后再做 action,如果找不到元素则会报错。这种方式的特点是不知道在系统和 App 中发生了什么, 把App当做黑盒去测试。

  • 测试经常因为不确定的随机原因挂掉。 因为黑盒测试框架并不能正确得到App是否执行完之前的 action, 通常会写很多 sleep 语句来保证 action 在执行时 App 处于闲置的状态。

  • 在 React Native 中传统的黑盒测试框架会遇到更多的问题, 因为RN有两个 thread 控制 App 的渲染(js 线程和 native 线程),会更难控制 App 的行为。

  • RN App 第一次打开的时候需要 load 和 parse js bundle, 黑盒测试框架需要sleep不确定的时间来等待这个过程(通常需要15到30秒)。

比如传统的一些测试框架: Appium/Robotium/Calabash等, 当测试用例比较多的时候经常随机的挂掉一些 case 但其实并没有 bug;因为添加了大量 sleep 语句导致测试运行的很慢;setup 起来相对比较麻烦, 经常需要好几个小时来搭建测试环境; Robotium 和 Calabash 的开发维护团队几乎已经停止支持这些框架了。

为了解决这个问题, Detox 利用 Earl Grey 和 Espresso 实现了灰盒的自动化测试。 特点如下:

  • 从 App 的内部来monitor App 的行为, 保证测试用例的指令和 App 的行为是同步的。

  • 和App在同一个进程中,可以访问App执行时的内存, 可以monitor在进程中在执行的任务。比如网络请求是否完成/主线程是否空闲/其他的线程是不是空闲/Animation(动画)是否结束/React Native Bridge是否空闲等等。

  • Detox会自动的监视App里的所有Async任务, 确保App完全闲置, UI hierarchy也不会变化的时候再执行下一步。因此从根本上保证了测试用例和App行为的同步, 不需要加wait或者sleep条件来判断 App 的状态。

其他的一些优点:

  • Detox支持Android和iOS。我们的 React Native 在iOS和Android的代码几乎相同, 因此也可以复用一套E2E的测试 case 。

  • 支持各种Test runner, 比如mocha, AVA,jest等。 可以根据个人喜好选择, 我们为了和单元测试保持一致, 选择了 Jest 作为 test runner。

  • 在 React Native 中可以根据TestID定位元素,对原本的代码侵入性较小(有些RN的测试框架需要额外的Component wrapper或者用ref来定位元素,侵入性相对较大)。

  • 搭建环境相对比较简单。运行的时候只需要detox build命令来编测试app和detox test来执行脚本即可。

  • Detox的特性自然保证了在测试刚开始运行的时候等待load和parse js bundle, 然后立刻开始运行测试脚本。

  • 提供了API来reload React Native - await device.reloadReactNative();

  • 一些使用RN的大公司比如Microsoft, Callstack.io, Wix都贡献了一些资源来维护开发这个框架, github社区也相对比较活跃。

着重介绍一下Detox自动同步的原理:

先举个例子 - Detox case vs. Calabash (之前我们选用的测试框架,语言是ruby):
比如我们要点击ButtonA, 进入第二个页面后点击ButtonB. 2个页面之间有一些animation和network request。
传统的Calabash case可能会这么写:

touch "* id:'ButtonA'"
wait_for_element_exists "* id: 'ButtonB'"
sleep 2
touch "* id: 'ButtonB'"

原因是在 animation 的时候可能 ButtonB 已经在 View 里存在了, 但其实是并不可点的(模拟器比较慢的时候更容易遇到)。为了减少 case 不必要的 fail, 就迫不得已的加了一些 sleep 语句。
如果sleep的时间少, 当测试运行的机器比较慢的时候就会 fail, sleep 多了自然 case 就慢了。

在detox的case写起来就比较直观了:

await element(by.id('ButtonA')).tap();
await element(by.id('ButtonB')).tap();

detox的每一个步测试方法都是 async 的, 当 ButtonA 被点击之后,App 的各种线程, animation, 网络请求, 异步方法等等统统运行完毕,App 完全空闲的时候 tap 方法才会resolve。

因此当测试运行到第二步的时候, ButtonB一定是处于可点击状态的, 不需要再用sleep或者wait方法来保证ButtonB的状态。
这就是所谓的自动同步(Automatically synchronized)。(同步指测试脚本和 App 的执行是按预期顺序执行的)。

具体实现方式Detox的底层依赖于 Earl Grey 和 Espresso, 这两个灰盒测试框架分别在 iOS 和 Android 的 native 进程了保证了测试框架和 App 同步。
利用 App 的内部资源或者监听一些 callback 来得知 App 是否空闲; 并且只有在App空闲时执行下一步测试。 此外 Detox 在 React Native 的js线程里也实现了类似的技术来得知JS是否执行完毕。

Detox 的测试脚本有点是写起来直观,执行起来非常的稳定可靠和快速。 同时也有一些副作用比如:

  • 在进程中执行了额外的代码来监听 App 的行为

  • 无限重复的动画会让脚本一直处于等待状态,需要额外的代码让自动化测试的build去掉无限循环的动画。

我觉得对我们来说值得承担这些风险来获得detox提供的在效率,可靠性方面的巨大提升。

最后附加一个 example 的E2E测试用例,可以看出 Detox 的 Api 还是很清晰易懂的,几乎没有什么学习成本:


describe('Login flow', () => {
    
  it('should login successfully', async () => {
    await device.reloadReactNative();
    await expect(element(by.id('email'))).toBeVisible();
      
    await element(by.id('email')).typeText('john@example.com');
    await element(by.id('password')).typeText('123456');
    await element(by.text('Login')).tap();
      
    await expect(element(by.text('Welcome'))).toBeVisible();
    await expect(element(by.id('email'))).toNotExist();
  });
  
});

单元测试部分请参考: 如何自动化测试 React Native 项目(下篇) - 单元测试