前言
我曾经在好几个项目里都近乎完整参与过补齐前端测试的工作,也收集到不同项目的同事很多关于前端测试的困惑和痛点,这其中大部分都很相似,我也感同身受,在这篇文章里,我会针对大家和自己常遇到的痛点分享一些自己的经验,如果你也有如下相似的困扰,那希望这篇文章能对你有些帮助~
常见问题(排名不分先后):
- 前端测试感觉写起来很复杂,会花很多时间,甚至经常是业务代码时间的好几倍
- 前端测试怎么TDD?
- 测试一些第三方UI控件时,特别难模拟与之的交互
- 有些东西不知道怎么mock,比如时间,浏览器全局变量(window.location,local storage)等
- 测试里准备数据的代码特别长,真正的测试代码很靠后,要翻很久,不容易定位
- 跑测试时会冒出很多Error或Warn Log,好像不影响测试通过,修起来也很花时间,还用修么?
在分享问题的相关经验之前,我们先来梳理一下前端测试体系~
前端测试体系
前端测试的重要性
这其实跟所有测试的重要性是一样的,大家有这么多的痛点也是因为知道覆盖全面的测试可以对代码质量更有保证,让我们更有信心地去重构代码,也能帮助我们更方便地了解现有的功能细节,甚至是一些极端的边界情况。而且在大家合作开发项目代码的过程中,测试可以帮助我们更早地发现错误,减少时间成本,提高交付效率。
前端测试方法论(TDD vs. BDD)
这两个常见的测试方法论在这里简单介绍一下,就不大篇幅展开了
TDD - (Test-Driven Development 测试驱动开发)简单地说就是先根据需求写测试用例,然后实现代码,通过后再接着写下一个测试和实现,循环直到全部功能和重构完成。基本思路就是通过测试来推动整个开发的进行。
BDD - (Behavior Driven Development 行为驱动开发) 其实可以看做是TDD的一个分支。简单地说就是先从外部定义业务行为,也就是测试用例,然后由外入内的实现这些行为,最后得到的测试用例也是相应业务行为的验收标准。
前端测试的分层
在这里借一下前端大牛Kent C. Dodds的奖杯分层法来引出常见的分类:
端到端测试 End to End Test
端到端测试一般会运行在完整的应用系统上(包括前端和后端),包含用户完整的使用场景,比如打开浏览器,从注册或登录开始,在页面内导航,完成系统提供的功能,最后登出。
有时,我们也会在这里引入可视化用户界面测试,即一种通过像素级比较屏幕截屏来验证页面显示是否正确的测试。目的是确保界面在不同设备、浏览器、分辨率和操作系统下与预期的样式一致。可以设置一定的偏差容忍值。
这一层的测试成本较高,所以通常重心会放在确保主流程的功能正常上。
常用工具:Cypress、Playwright、Puppeteer、TestCafe、Nightwatch (下载量对比)
集成测试 Integration Test
集成测试主要是测试当单元模块组合到一起之后是否功能正常。
在不同的测试上下文下可能有不同的定义,在前端测试这里通常指测试集成多个单元组件到一起的组件。
单元测试 Unit Test
单元测试就是对没有依赖或依赖都被mock掉了的测试单元的测试。在前端代码里,它可能是:
- 没有依赖或依赖都被mock掉了的单元组件
- 功能代码如Utils/Helpers等公共方法集合的测试
- 辅助组件功能如React Hook / Selector等公共方法的测试
静态代码测试 Static Test
主要是指利用一些代码规范工具(Lint Tool)来及时捕获代码中潜在的语句错误,统一代码格式等。这里就不展开了。
常见工具和实践有:
Eslint + Prettier 代码规范和样式统一
husky + lint-staged (gitHooks工具)可以自动在commit和push之前进行代码扫描,阻止不规范代码进入代码库,也可以设置在push之前跑一遍前端测试
前端测试策略
还是这张图,我标记了一下:
- 越往上成本越高
- 越往上得到反馈的速度越慢
- 但越往上,越贴近最终用户的行为,越能发现真实的问题,能给到的信心就更多
在奖杯的形状上每一层占的面积代表了应该投入的重心比例。
这里集成测试的比重比单元测试大是因为集成测试可以在成本很高的e2e测试和离最终用户行为较远的单元测试之间取的一个平衡,它可以写的很接近最终用户的行为,成本又相对的没那么高,属于性价比很高的一部分。
所以集成测试有一些原则:
- 可以根据每个页面的复杂程度决定是只有一个全页面的集成测试还是可以划分成几大块分别有集成测试,但一旦作为集成测试,就要尽可能的少mock依赖,尽量的渲染全子组件
- 尽量测试用户的所见和交互,而不是背后的实现,否则就会远离最终用户行为,降低信心值,而且随着代码的重构,测试也需要频繁的修改。比如Enzyme可以把component里的方法、props、state等都提供出来单独测试,但这里的测试并不贴近真实用户的交互,很容易就会因为重构而破坏测试,更好的方法是真的去测试当props和state变化后页面的变动,或交互的变化
- 准备的测试数据尽量丰富且贴近真实数据(用户敏感信息要替换掉),越贴近真实的数据越能覆盖到更多真正的问题
- 对于核心的业务行为,要重点测试
对于单元测试来说:
- UI组件类的测试:因为有了集成测试的覆盖,可以简单的测试一下不同props的渲染,如果有一些集成测试覆盖不到的特殊数据引发的交互行为,可以测试一下
- 非UI组件类的测试:通常会覆盖一些复杂的业务逻辑,需要全面的测试一下不同的分支条件
前端测试工具的分类
测试启动工具 (Test Launchers)
测试启动工具负责将测试运行在Node.js或浏览器环境。形式可能是CLI或UI,并结合一定的配置。常见工具有:Jest / Karma / Jasmine / Cypress / TestCafe 等
测试结构工具 (Structure Providers)
测试结构工具提供一些方法和结构将测试组织的更好,拥有更好的可读性和可扩展性。如今,测试结构通常以BDD形式来组织。测试结构如下方Jest例子:
// Jest test structure
describe('calculator', () => {
// 第一层级: 标明测试的模块名称
beforeEach(() => {
// 每个测试之前都会跑,可以统一添加一些mock等
})
afterEach(() => {
// 每个测试之后都会跑,可以统一添加一些清理功能等
})
describe('add', () => {
// 第二层级: 标明测试的模块功能分组
test('should add two numbers', () => {
// 实际的描述业务需求的测试
...
})
})
})
常见工具有:Jest / Mocha / Cucumber / Jasmine / Cypress / TestCafe 等
断言库 (Assertion Functions)
断言库会提供一系列的方法来帮助验证测试的结果是否符合预期。如下方的例子:
// Jest expect (popular)
expect(foo).toEqual('bar')
expect(foo).not.toBeNull()
// Chai expect
expect(foo).to.equal('bar')
expect(foo).to.not.be.null
常见工具有:Jest / Chai / Assert / TestCafe 等
Mock工具
有的时候我们在测试的时候需要隔离一些代码,模拟一些返回值,或监控一些行为的调用次数和参数,比如网络请求的返回值,一些浏览器提供的功能,时间计时等,Mock工具会帮助我们更容易的去完成这些功能。
常见工具有:Sinon / Jest (spyOn, mock, useFakeTimers…) 等
快照测试工具 (Snapshot Comparison)
快照测试对于UI组件的渲染测试十分有效。原理是第一次运行时生成一张快照文件,需要开发人员确认快照的正确性,之后每一次运行测试都会生成一张快照并与之前的快照做比较,如果不匹配,则测试失败。这时如果新的快照确实是更新代码后的正确内容,则可以更新之前保存的快照。(这里的快照通常都是框架渲染器生成的序列化后的字符串,而不是真实的图片,这样的测试效率比较高)
这里可以参考Jest官方的用例。
常见工具有:Jest / Ava / Cypress
测试覆盖率工具(Test Coverage)
测试覆盖率工具可以产出测试覆盖率报告,通常会包含行、分支、函数、语句等各个维度的代码覆盖率,还可以生成可视化的html报告来可视化代码覆盖率。
如以下的Jest内置的代码覆盖率报告:
常见工具有:Jest内置 / Istanbul
E2E 测试工具(End to End Test)
上面在测试分层里介绍过的。
可视化用户界面测试(Visual Regression)
也在上面的测试分层里介绍过。通常会和e2e测试工具组合在一起使用,一般主流的e2e测试工具也会有对应的库去进行可视化用户界面测试。
前端框架专属测试库
不同的前端框架还会有一些自带的或推荐的测试库,比如:
React: React官方的Test Utils / Testing Library - React(推荐) / Enzyme (基于上面的测试策略,更推荐React Testing Library,Enzyme暴露了太多内部元素用来测试,虽然一时方便,但远离了用户行为,之后带来的修改频率也比较高,性价比低)
Vue: Vue官方的Test Utils / Testing Library - Vue
Angular: Angular内置的测试框架(Jasmine) / Testing Library - Angular
前端测试框架
基于上面的分类,大家可能发现几乎哪哪都有Jest,这类大而全的前端测试工具我们也可以称为前端测试框架。
常见的有:
Jest:大力推荐,几乎有测试需要的所有工具,社区活跃,网上资源丰富,也是React官方推荐的测试框架
Mocha:虽然也功能丰富,但没有断言库、测试覆盖率工具和Mock工具,需要和其他第三方库配合使用
Jasmine:比较老派的工具,功能也没有Jest丰富,下载率逐年下降
最后附上一张stateOfJS网站2021年的测试库满意度图表供大家参考
前端测试的常见问题
终于回到最开始的问题了,分享一下我的经验和通常的解决办法:
前端测试感觉写起来很复杂,会花很多时间,甚至经常是业务代码时间的好几倍
这个问题可以分成三部分来下手:
优化测试策略
可以根据刚才的测试策略部分,结合自己项目的实际情况,调整一下在不同的测试层分配的重心,定一下自己项目每个层级的测试粒度,这样才能在保证交付的前提下达到测试信心值收益的最大化
提升写测试效率
- 抽取公共的部分,使具体的测试文件简洁
- 准备数据的fixture库,可以轻松的生成想要的store数据或请求返回数据
- 公共的render方法,可以支持自定义store, stub子组件, mock框架全局方法等
- 公共的第三方UI组件交互方法,可以轻松的触发第三方控件的事件,不用再关心实现细节
- 公共的api mock方法,可以在测试文件里不用关心api细节,轻松mock
- 统一测试规范,有优化及时重构所有测试,这样大家可以放心的参考已有测试,不会有多种写法影响可读性
提升运行测试的效率
- 并行跑测试
- 测试里常用如下方法使待测的异步请求返回,通常也会给setTimeout一个等待时间,大部分的情况0就可以达到目的了,除非是逻辑真的要等待一定的时间,如果默认值都设置的比较大,每个测试都会耽误一些时间,加起来对测试运行性能的影响是很大的
// testUtils.js
export const flushPromises = (interval = 0) => {
return new Promise((resolve) => {
setTimeout(resolve, interval);
});
};
// example.test.js
test('should show ...', async () => {
//render component
await flushPromises();
//verify component
});
前端测试怎么TDD
通常问这个问题背后隐藏的问题是前端很难先写测试,再写实现。确实我也有同感,如果是一些util/helper方法是可以很容易的遵循TDD的步骤的,但当涉及页面结构和样式的时候,很难在写测试的时候就想清楚页面到底有哪些具体的元素,用到哪些需要mock的模块。
所以在测试UI组件时,我通常会使用BDD的方式,具体步骤是:
- 建立组件文件,渲染返回空
- 建立测试文件,先写一个snapshot测试,测试会通过,生成一个snapshot文件
- 再根据这个页面mockup上已知的交互写好test case,通常这个时候不太容易写实现,就先把测试用例都写好,test先skip起来,eslint可以设置成skip的test用warn来展示,这样之后方便补全
// Jest
describe('todo component', () => {
test('should show todo list', () => {
// Snapshot test
const tree = renderer.create( ).toJSON();
expect(tree).toMatchSnapshot();
})
test.skip('should add todo when click add and input todo content', () => {
})
test.skip('should remove todo when click delete icon of todo item', () => {
})
- 随着页面重构,可能会给组件添加props,这时也需要给不同的props添加snapshot测试或交互测试
- 最后可以根据测试跑完的测试覆盖率报告看看是否覆盖全面了,防止有遗漏
当然随着前端代码写的越来越熟练,为了提升效率,有时会简化步骤,等一个小功能的组件都重构完了,样式调好了,所有的子组件都抽完了,再根据每个组件的props和交互的点批量加测试,最后用测试覆盖率来验证是否都覆盖到了,保证自己新写的组件都尽可能是100%的覆盖率。
测试一些第三方UI控件时,特别难模拟与之的交互
这个是我也很头疼的问题,有的时候一些第三方组件因为要实现一些复杂的效果,会使用不一样的方式去监听事件。
比如我们有一个Vue项目上用到了element-ui的select组件,这个组件可以通过:remote-method
属性开启异步发请求加载选项的功能,测试里想模拟异步拿到选项后并选择某选项,就需要想办法触发它的@change
事件,通常一条await ***fireEvent***.update(input, 'S');
就搞定了,但这个怎么都不生效,仔细的查看它的实现才发现需要这么一串操作才能触发到@change
事件。
const input = getByPlaceholderText('Please input to search');
await fireEvent.click(input);
await fireEvent.keyUp(input, { key: 'A', code: 'KeyA' });
await fireEvent.update(input, 'A');
await flushPromises(500); // 这个方法上面有介绍,的作用是让异步的代码返回结果,并且等待500ms,因为源码有500ms的等待,这里就也需要等待
await fireEvent.click(getByText('Apple'));
这里我总结的经验就是:
- 如果发现常用的交互方法不能生效,需要去研究第三方组件的源码
- 更重要的是如果大家研究出来了方法,及时的把相关代码抽到一个公共的util文件里,这样之后就不会有人也花费很多时间在上面了,确实经常遇到大家重复卡在相同的第三方组件交互问题上而不知道已经有代码解决了的场景
有些东西不知道怎么mock,比如时间,浏览器全局变量(window.location,local storage)等
这个可以结合使用的测试工具去搜索,一般都会有很多现成的解决方案,在这里举两个例子:
Mocknavigator.userAgent
// jest.setup.js
Object.defineProperty(
global.navigator,
'userAgent',
((value) => ({
get() { return value; },
set(v) { value = v; },
}))(global.navigator['userAgent']),
);
// example.test.js
test('should show popup in Safari', () => {
global.navigator.userAgent = 'user agent of Safari ...';
// render and verify something
});
Mock window.open
//jest.setup.js
Object.defineProperty(
window,
'open',
((value) => ({
get() { return value; },
set(v) { value = v; },
}))(window.open),
);
// example.test.js
test('should ...', () => {
window.open = jest.fn();
// render something
expect(window.open).toBeCalledWith('xxx', '_blank');
});
测试里准备数据,mock依赖的代码特别长,真正的测试代码很靠后,要翻很久,不容易定位
上面有介绍,可以将公共的部分抽取出去,又能减少代码重复,又能提升写测试的效率
比如准备数据的部分可以抽成公共的fixture文件,提供方法生成默认的数据,也可以通过参数去覆盖修改部分数据,达到定制化的目的
export const generateUser = (user = {}) => {
return {
id: 1,
firstName: 'San',
lastName: 'Zhang',
email: '[email protected]',
...user,
};
};
跑测试时会冒出很多Error或Warn Log,好像不影响测试通过,修起来也很花时间,还用修么?
测试里的报错通常都很有价值,需要重视。这里面的错误有可能是:
- 前端框架相关的,比如被测的组件有写的或调用的不合理的情况,这种有的时候不仅是测试调用组件方式的问题,有可能业务代码也写的有问题;或者是测试语句写的不合理,如React的 not wrapped in act(...)
- 测试运行相关的,比如有些请求没有mock,测试里一直等不到返回值而timeout了,但又不是主测的业务,所以测试还是会通过,之前有遇到很多次测试并行跑时会互相影响,随机挂,如果log里有类似这种timeout的内容,很有可能就是原因,mock好了所有的请求后问题就解决了
虽然有的时候也会有一些由于第三方库的原因引起的无法修复又没有影响的log,可以忽略,但测试里大部分警告Log其实都是可以修复的,甚至在修复后可能得到意想不到的受益,比如发现真正业务代码的问题,测试不再随机挂了,测试运行性能提升了等等。
总结
对于前端测试,我觉得重心不是机械的去追求测试覆盖率,而是尽可能的在成本和信心值中间找到一个平衡,应用一些好的实践去降低写测试的成本,提升写测试带来的回报,让大家对于项目质量越来越有信心。
文/Thoughtworks 张霄翀
原文链接:前端测试体系和最佳实践