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的方案。 自动化测试的重要性在这个时候就体现出来了:
测试金字塔 是目前比较流行的一种设计自动化测试的思路,核心观点如下:
简单介绍一下对 Unit, Integration 以及 E2E 自动化测试的想法:
E2E自动化测指通过UI来从头到尾(End-To-End)的测试 App 的工作流程是不是符合预期。 E2E的优点是可以模拟用户的真实scenario,代替手工测试来测试完整的集成系统。在任何自动化测试体系中,E2E都是最接近真实用户的,因此是最让人有信心的测试方法。
但实际应用中E2E测试的缺点也很明显:
因此全部用E2E进行自动化测试是不现实的。 我个人之前也试过写150+条E2E脚本来进行测试, 后来维护脚本的时间精力实在太大。因此我们需要更高效和容易维护的测试脚本来代替E2E测试。
单元测试通常指保证code中的一个单元正确工作的测试。 一个单元可以指一个方法, 一个class,甚至一个component; 可以按照code的结构进行划分。
单元测试的优点如下:
单元测试的缺点在于无法保证每个单元都正确, 当他们都组装在一起的时候也是正确的。
以上两种测试方法各有各的好处,我们应该选择利用两者的优点,并且让两种测试方法的缺点带来的风险更小。 这也符合前面测试金字塔中讲过的观点 - 用大量的单元测试来保证每个单元都是正确工作的, 同时用少量的更高层测试来保证集成起来也是正确工作的。
在维护自动化测试时,我的经验是:
之前讲过单元测试的风险在于每个单元分别都是正确工作的不等于放在一起也是正确工作的。 这时候除了用E2E测试来做集成, 还可以用把几个单元组装在一起的集成测试的方法来减少这种风险。
集成测试的好处:
以上图举个例子: 比如 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条逻辑。
我会在后文中具体介绍在 Glow 我们选择用来实现这套自动化测试系统的框架以及详细的实现方法。
Detox是Wix公司开源的一款灰盒自动化测试框架。底层使用了Google开源的 Earl Grey(iOS)和 Espresso(Android)。
在详细介绍Detox之前先简单介绍下传统黑盒自动化测试框架的特点和问题:
比如传统的一些测试框架: Appium/Robotium/Calabash等, 当测试用例比较多的时候经常随机的挂掉一些 case 但其实并没有 bug;因为添加了大量 sleep 语句导致测试运行的很慢;setup 起来相对比较麻烦, 经常需要好几个小时来搭建测试环境; Robotium 和 Calabash 的开发维护团队几乎已经停止支持这些框架了。
为了解决这个问题, Detox 利用 Earl Grey 和 Espresso 实现了灰盒的自动化测试。 特点如下:
其他的一些优点:
await device.reloadReactNative();
着重介绍一下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 的测试脚本有点是写起来直观,执行起来非常的稳定可靠和快速。 同时也有一些副作用比如:
我觉得对我们来说值得承担这些风险来获得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();
});
});