前端单元测试调研
英国Just Eat的首席UI工程师Ashley Watson-Nolan做过这样一个调查,16年有48.32%的前端开发者没有做过任何前端代码测试,这个数字到18年下降了4.32%, 到19年则下降到了21%,可见前端测试这一环节在前端开发者中的普及率明显上升,下图是18年和19年该调查中开发者对测试工具的选择和使用情况汇总
什么是单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证,对于JavaScript来说,通常也是针对函数、对象和模块的测试。 单元测试的特性--
- 只要输入不变,必定返回同样的输出
- 不依赖外部系统
- 运行速度很快(毫秒级别)
- 不造成测试环境的脏数据
- 可重复运行
一个软件越容易写单元测试,也就表明它的模块化结构越好,各模块之间的耦合越弱。React的组件化和函数式编程,天生就适合进行单元测试
为什么要进行单元测试
没有单测的情况:
- 质量主要依赖自己保障
- 开发依赖手工测试,耗时耗力
- 代码更新频繁时,每次迭代新功能难保证已有核心功能测试完整性
- 对于复杂的功能,回归困难,工作量大
单元测试的优点:
- 提高代码质量:单元测试可以帮助我们反思模块划分的合理性,如果一个单元测试写得逻辑非常复杂、或者说一个函数复杂到无法写单测,那就说明模块的抽象有问题。
- 提升可维护性:单元测试使得系统具备更好的可维护性,可读性,便于多人同时开发。
- 快速熟悉代码:对于团队的新人来说,单元测试其实是很好的"文档",每个case 都能详细的反映代码包含的具体功能和业务
- 效率提升:快速定位问题,提升调试效率,每次跑完单测查看不通过的case就能快速定位问题
- 放心重构:单元测试使得我们可以放心修改、重构业务代码,而不用担心修改某处代码后带来的副作用。
单元测试位于测试金字塔的最底层,越向上反馈的时间越长,实现的成本也越高。
单元测试实践的主要问题
通常会面临四大问题:
- 不愿做:程序员没有单元测试习惯
- 没时间:编写测试代码需要耗费一定的时间,项目的周期可能不允许
- 做不了:代码具有较高的耦合性,使单元测试难以进行
- 做不好:测试效果不能令人满意。实现高标准的测试覆盖很困难
如何应对解决以上问题
- 不愿做:将单元测试覆盖率纳入代码质量指标和研发流程,作为保障代码质量的一个硬性指标来推进,磨刀不误砍柴工,最终会形成一个良性循环
- 没时间:可以优先从基础组件库来推进单测的落地;循序渐进、多方协商、TL支持、自己争取
- 做不了:优先在新需求中实践单元测试,而耦合度较高、历史包袱沉重的代码,应该从重构的层面解决问题
- 做不好:"先做" 带动 "后做", 制定统一规范,多读多写多交流,CR评估测试有效性等
整体思路--
- 循序渐进,优先从基础组件库落地推进执行,可以把单测作为组件入组件库的一个硬性标准
- 集成到脚手架,使用git-commit hook 让单元测试流程化
- 重构代码必须加入单元测试
- 优先做非UI部分的单元测试
- 业务场景多、复杂或经常回归的场景可以多写些端到端测试
开发模式
- TDD: (Testing Driven Development)测试驱动开发 ,强调的是一种开发方式,以测试来驱动整个项目,它依赖于非常短的开发周期的重复: 需求被转化为具体的测试用例,通过不断的代码改进,通过测试用例,以"小步快跑" "持续循环" 的方式来完成整个项目。
- 写一个测试
- 运行这个测试,看到预期的失败
- 编写尽可能少的业务代码,让测试通过
- 重构代码
- 不断重复以上过程
- BDD: (Behavior Driven Development)行为驱动测试,BDD建议针对行为进行测试,我们不考虑如何实现代码,取而代之的是我们花时间考虑场景是什么,会有什么行为,针对行为 代码应该有什么反应。鼓励软件项目中的开发者、QA和非技术人员或商业参与者之间的协作
可以把BDD看作是在需求与TDD之间架起一座桥梁,它将需求进一步场景化,更具体的描述系统应该满足哪些行为和场景,让TDD的输入更优雅、更可靠。
单元测试框架对比
- 提供测试框架(Jest,Mocha, Jasmine, Cucumber)
- 提供断言(Jest,Chai, Jasmine, Unexpected)
- 生成,展示测试结果(Jest,Mocha, Jasmine, Karma)
- 快照测试(Jest, Ava)
- 提供仿真(Jest,Sinon, Jasmine, enzyme, testdouble)
- 生成测试覆盖率报告(Jest,Istanbul, Blanket)
- 提供类浏览器环境(Puppeteer, Nightwatch, Phantom, Casper)
推荐测试工具:Jest + Enzyme
Jest是Facebook开源的一个前端测试框架,主要用于React和React Native的测试,已被集成在create-react-app中。集成了断言库、mock库、测试覆盖率统计功能、 Snapshot 机制。Jest 几乎可以做0配置使用。
Enzyme是Airbnb开源的React测试工具库,它扩展了React的TestUtils,提供了一套简洁强大的 API,并内置Cheerio。 我们可以用Enzyme渲染react组件,遍历和操作react组件的输出,Enzyme提供了类Jquery风格简洁的API, 使得dom操作变得十分友好。在开源社区有超高人气,同时也获得了React 官方的推荐。
测试环境搭建
npm install jest enzyme babel-jest enzyme-adapter-react-16 enzyme-to-json --save-dev
复制代码
.jest.js文件
module.exports = {
setupFiles: ['./test/setup.js'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testPathIgnorePatterns: [
'/node_modules/',
],
testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$',
collectCoverage: false,
collectCoverageFrom: [
'src/components/**/*.{ts,tsx,js,jsx}',
],
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy',
},
transform: {
"^.+\\.js$": "babel-jest"
},
};
复制代码
- setupFiles:配置文件,在运行测试案例代码之前,Jest会先运行这里的配置文件来初始化指定的测试环境
- moduleFileExtensions:代表支持加载的文件名
- testPathIgnorePatterns:用正则来匹配不用测试的文件
- testRegex:正则表示的测试文件,测试文件的格式为xxx.test.js
- collectCoverage:是否生成测试覆盖报告
- collectCoverageFrom:生成测试覆盖报告时检测的覆盖文件
- moduleNameMapper:代表需要被Mock的资源名称
- transform:用babel-jest来编译文件,生成ES6/7的语法
enzyme
三种渲染
- shallow:浅渲染,是对官方的Shallow Renderer的封装。将组件渲染成虚拟DOM对象,只会渲染第一层,子组件将不会被渲染出来,使得效率非常高。不需要DOM环境, 并可以使用jQuery的方式访问组件的信息
- render:静态渲染,它将React组件渲染成静态的HTML字符串,然后使用Cheerio这个库解析这段字符串,并返回一个Cheerio的实例对象,可以用来分析组件的html结构
- mount:完全渲染,它将组件渲染加载成一个真实的DOM节点,用来测试DOM API的交互和组件的生命周期。用到了jsdom来模拟浏览器环境
常用方法
simulate(event, mock)
模拟事件,用来触发事件,event为事件名称,mock为一个event objectinstance()
返回组件的实例find(selector)
根据选择器查找节点,selector可以是CSS中的选择器,或者是组件的构造函数,组件的display name等at(index)
返回一个渲染过的对象get(index)
contains(nodeOrNodes)
当前对象是否包含参数重点 node,参数类型为react对象或对象数组text()
返回当前组件的文本内容html()
props()
返回根组件的所有属性prop(key)
返回根组件的指定属性state()
返回根组件的状态setState(nextState)
设置根组件的状态setProps(nextProps)
设置根组件的属性
jest
常见断言
expect(value)
要测试一个值进行断言的时候,要使用expect对值进行包裹.not
取反.toBe(value)
使用Object.is来进行比较,如果进行浮点数的比较,要使用toBeCloseTo.toHaveBeenCalled()
用来判断mock function是否被调用过.toHaveBeenCalledTimes(number)
用来判断mock function被调用的次数.toBeNull()
只匹配null.toBeUndefined()
只匹配undefined.toBeDefined()
与toBeUndefined相反.toBeTruthy()
匹配任何使if语句为真的值.toBeFalsy()
匹配任何使if语句为假的值.toBeGreaterThan(number | bigint)
>.toBeGreaterThanOrEqual(number | bigint)
>=.toBeLessThan(number | bigint)
<.toBeLessThanOrEqual(number | bigint)
.toBeInstanceOf(Class)
判断是不是class的实例.toContain(item)
用来判断item是否在一个数组中,也可以用于字符串的判断.toEqual(value)
用于对象的深比较.toMatch(regexpOrString)
用来检查字符串是否匹配,可以是正则表达式或者字符串.resolves
用来取出promise为fulfilled时包裹的值,支持链式调用.rejects
用来取出promise为rejected时包裹的值,支持链式调用
钩子
执行Jest测试用例之前需要做一些配置,在用例执行完做一些清除操作,那你需要了解下面4个API
beforeEach(fn, timeout)
在每个test用例执行前执行,用于准备测试数据afterEach(fn, timeout)
在每个test用例执行后执行,用于清理测试数据beforeAll(fn, timeout)
在所有test用例执行前执行回调callbackafterAll(fn, timeout)
在所有test用例执行后执行回调callback
mock
使用mock函数可以轻松的模拟代码之间的依赖,可以通过fn或spyOn来mock某个具体的函数;通过mock来模拟某个模块。具体的API可以看mock-function-api。
异步测试
- return promise
当对Promise进行测试时,一定要在断言之前加一个return,不然没有等到Promise的返回,测试函数就会结束。可以使用.promises/.rejects对返回的值进行获取,或者使用then/catch方法进行判断。
- async/await
使用async不用进行return返回,并且要使用try/catch来对异常进行捕获。
Snapshot Testing
快照测试可以用于保证界面不出现异常变化。 快照会生成一个组件的UI结构,并用字符串的形式存放在__snapshots__文件里,通过比较两个字符串来判断UI是否改变,因为是字符串比较,所以性能很高。 要使用快照功能,需要引入react-test-renderer库,使用其中的renderer方法,jest在执行的时候如果发现toMatchSnapshot方法,会在同级目录下生成一个__snapshots文件夹用来存放快照文件,以后每次测试的时候都会和第一次生成的快照进行比较。可以使用jest --updateSnapshot来更新快照文件。
import React from 'react'
import renderer from 'react-test-renderer'
it('renders correctly', () => {
const tree = renderer
.create(<TodoList {...props} />)
.toJSON();
expect(tree).toMatchSnapshot();
});
复制代码
单测覆盖率
Jest集成了Istanbul这个代码覆盖工具, 提供了生成测试覆盖率报告的命令 --coverage
四个测量维度--
- 行覆盖率(line coverage):是否测试用例的每一行都执行了
- 函数覆盖率(function coverage):是否测试用例的每一个函数都调用了
- 分支覆盖率(branch coverage):是否测试用例的每个if代码块都执行了
- 语句覆盖率(statement coverage):是否测试用例的每个语句都执行了