React单元测试方案前置知识为什么要进行测试测试可以确保得到预期的结果作为现有代码行为的描述促使开发者写可测试的代码一般可测试的代码可读性也会高一点如果依赖的组件有修改受影响的组件能在测试中发现错误测试类型单元测试指的是以原件的单元为单位对软件进行测试。单元可以是一个函数也可以是一个模块或一个组件基本特征就是只要输入不变必定返回同样的输出。一个软件越容易些单元测试就表明它的模块化结构越好给模块之间的耦合越弱。React的组件化和函数式编程天生适合进行单元测试功能测试相当于是黑盒测试测试者不了解程序的内部情况不需要具备编程语言的专门知识只知道程序的输入、输出和功能从用户的角度针对软件界面、功能和外部结构进行测试不考虑内部的逻辑集成测试在单元测试的基础上将所有模块按照设计要求组装成子系统或者系统进行测试冒烟测试在正式全面的测试之前对主要功能进行的与测试确认主要功能是否满足需要软件是否能正常运行开发模式TDD: 测试驱动开发英文为Testing Driven Development强调的是一种开发方式以测试来驱动整个项目即先根据接口完成测试编写然后在完成功能是要不断通过测试最终目的是通过所有测试BDD: 行为驱动测试英文为Behavior Driven Development强调的是写测试的风格即测试要写的像自然语言让项目的各个成员甚至产品都能看懂测试甚至编写测试TDD和BDD有各自的使用场景BDD一般偏向于系统功能和业务逻辑的自动化测试设计而TDD在快速开发并测试功能模块的过程中则更加高效以快速完成开发为目的。技术选型Jest EnzymeJestJest是Facebook开源的一个前端测试框架主要用于React和React Native的单元测试已被集成在create-react-app中。Jest特点易用性基于Jasmine提供断言库支持多种测试风格适应性Jest是模块化、可扩展和可配置的沙箱和快照Jest内置了JSDOM能够模拟浏览器环境并且并行执行快照测试Jest能够对React组件树进行序列化生成对应的字符串快照通过比较字符串提供高性能的UI检测Mock系统Jest实现了一个强大的Mock系统支持自动和手动mock支持异步代码测试支持Promise和async/await自动生成静态分析结果内置Istanbul测试代码覆盖率并生成对应的报告EnzymeEnzyme是Airbnb开源的React测试工具库库它功能过对官方的测试工具库ReactTestUtils的二次封装提供了一套简洁强大的 API并内置Cheerio实现了jQuery风格的方式进行DOM 处理开发体验十分友好。在开源社区有超高人气同时也获得了React 官方的推荐。测试环境搭建安装Jest、Enzyme以及babel-jest。如果React的版本是15或者16需要安装对应的enzyme-adapter-react-15和enzyme-adapter-react-16并配置。import Enzyme from enzyme; import Adapter from enzyme-adapter-react-16; Enzyme.configure({ adapter: new Adapter() });在package.json中的script中增加test: jest --config .jest.js.jest.js文件 module.exports { setupFiles: [ ./test/setup.js, ], moduleFileExtensions: [ js, jsx, ], testPathIgnorePatterns: [ /node_modules/, ], testRegex: .*\\.test\\.js$, collectCoverage: false, collectCoverageFrom: [ src/components/**/*.{js}, ], moduleNameMapper: { \\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$: rootDir/__mocks__/fileMock.js, \\.(css|less|scss)$: rootDir/__mocks__/styleMock.js }, transform: { ^.\\.js$: babel-jest }, };setupFiles配置文件在运行测试案例代码之前Jest会先运行这里的配置文件来初始化指定的测试环境moduleFileExtensions代表支持加载的文件名testPathIgnorePatterns用正则来匹配不用测试的文件testRegex正则表示的测试文件测试文件的格式为xxx.test.jscollectCoverage是否生成测试覆盖报告如果开启会增加测试的时间collectCoverageFrom生成测试覆盖报告是检测的覆盖文件moduleNameMapper代表需要被Mock的资源名称transform用babel-jest来编译文件生成ES6/7的语法Jestglobals APIdescribe(name, fn)描述块讲一组功能相关的测试用例组合在一起it(name, fn, timeout)别名test用来放测试用例afterAll(fn, timeout)所有测试用例跑完以后执行的方法beforeAll(fn, timeout)所有测试用例执行之前执行的方法afterEach(fn)在每个测试用例执行完后执行的方法beforeEach(fn)在每个测试用例执行之前需要执行的方法全局和describe都可以有上面四个周期函数describe的after函数优先级要高于全局的after函数describe的before函数优先级要低于全局的before函数beforeAll(() { console.log(global before all); }); afterAll(() { console.log(global after all); }); beforeEach(() { console.log(global before each); }); afterEach(() { console.log(global after each); }); describe(test1, () { beforeAll(() { console.log(test1 before all); }); afterAll(() { console.log(test1 after all); }); beforeEach(() { console.log(test1 before each); }); afterEach(() { console.log(test1 after each); }); it(test sum, () { expect(sum(2, 3)).toEqual(5); }); it(test mutil, () { expect(sum(2, 3)).toEqual(7); }); });configJest拥有丰富的配置项可以写在package.json里增加增加jest字段来进行配置或者通过命令行--config来指定配置文件。jest对象jest.fn(implementation)返回一个全新没有使用过的mock function这个function在被调用的时候会记录很多和函数调用有关的信息jest.mock(moduleName, factory, options)用来mock一些模块或者文件jest.spyOn(object, methodName)返回一个mock function和jest.fn相似但是能够追踪object[methodName]的调用信息类似SinonMock Functions使用mock函数可以轻松的模拟代码之间的依赖可以通过fn或spyOn来mock某个具体的函数通过mock来模拟某个模块。具体的API可以看mock-function-api。快照快照会生成一个组件的UI结构并用字符串的形式存放在__snapshots__文件里通过比较两个字符串来判断UI是否改变因为是字符串比较所以性能很高。要使用快照功能需要引入react-test-renderer库使用其中的renderer方法jest在执行的时候如果发现toMatchSnapshot方法会在同级目录下生成一个__snapshots文件夹用来存放快照文件以后每次测试的时候都会和第一次生成的快照进行比较。可以使用jest --updateSnapshot来更新快照文件。异步测试Jest支持对异步的测试支持Promise和Async /Await两种方式的异步测试。常见断言expect(value)要测试一个值进行断言的时候要使用expect对值进行包裹toBe(value)使用Object.is来进行比较如果进行浮点数的比较要使用toBeCloseTonot用来取反toEqual(value)用于对象的深比较toMatch(regexpOrString)用来检查字符串是否匹配可以是正则表达式或者字符串toContain(item)用来判断item是否在一个数组中也可以用于字符串的判断toBeNull(value)只匹配nulltoBeUndefined(value)只匹配undefinedtoBeDefined(value)与toBeUndefined相反toBeTruthy(value)匹配任何使if语句为真的值toBeFalsy(value)匹配任何使if语句为假的值toBeGreaterThan(number) 大于toBeGreaterThanOrEqual(number)大于等于toBeLessThan(number)小于toBeLessThanOrEqual(number)小于等于toBeInstanceOf(class)判断是不是class的实例anything(value)匹配除了null和undefined以外的所有值resolves用来取出promise为fulfilled时包裹的值支持链式调用rejects用来取出promise为rejected时包裹的值支持链式调用toHaveBeenCalled()用来判断mock function是否被调用过toHaveBeenCalledTimes(number)用来判断mock function被调用的次数assertions(number)验证在一个测试用例中有number个断言被调用extend(matchers)自定义一些断言Enzyme三种渲染方法shallow浅渲染是对官方的Shallow Renderer的封装。将组件渲染成虚拟DOM对象只会渲染第一层子组件将不会被渲染出来使得效率非常高。不需要DOM环境 并可以使用jQuery的方式访问组件的信息render静态渲染它将React组件渲染成静态的HTML字符串然后使用Cheerio这个库解析这段字符串并返回一个Cheerio的实例对象可以用来分析组件的html结构mount完全渲染它将组件渲染加载成一个真实的DOM节点用来测试DOM API的交互和组件的生命周期。用到了jsdom来模拟浏览器环境三种方法中shallow和mount因为返回的是DOM对象可以用simulate进行交互模拟而render方法不可以。一般shallow方法就可以满足需求如果需要对子组件进行判断需要使用render如果需要测试组件的生命周期需要使用mount方法。常用方法simulate(event, mock)模拟事件用来触发事件event为事件名称mock为一个event objectinstance()返回组件的实例find(selector)根据选择器查找节点selector可以是CSS中的选择器或者是组件的构造函数组件的display name等at(index)返回一个渲染过的对象get(index)返回一个react node要测试它需要重新渲染contains(nodeOrNodes)当前对象是否包含参数重点 node参数类型为react对象或对象数组text()返回当前组件的文本内容html() 返回当前组件的HTML代码形式props()返回根组件的所有属性prop(key)返回根组件的指定属性state()返回根组件的状态setState(nextState)设置根组件的状态setProps(nextProps)设置根组件的属性编写测试用例组件代码todo-list/index.js import React, { Component } from react; import { Button } from antd; export default class TodoList extends Component { constructor(props) { super(props); this.handleTest2 this.handleTest2.bind(this); } handleTest () { console.log(test); } handleTest2() { console.log(test2); } componentDidMount() {} render() { return ( div classNametodo-list {this.props.list.map((todo, index) (div key{index} span classNameitem-text {todo}/span Button onClick{() this.props.deleteTodo(index)} done/Button /div))} /div ); } }测试文件setup设置const props { list: [first, second], deleteTodo: jest.fn(), }; const setup () { const wrapper shallow(TodoList {...props} /); return { props, wrapper, }; }; const setupByRender () { const wrapper render(TodoList {...props} /); return { props, wrapper, }; }; const setupByMount () { const wrapper mount(TodoList {...props} /); return { props, wrapper, }; };使用 snapshot 进行 UI 测试it(renders correctly, () { const tree renderer .create(TodoList {...props} /) .toJSON(); expect(tree).toMatchSnapshot(); });当使用toMatchSnapshot的时候会生成一份组件DOM的快照以后每次运行测试用例的时候都会生成一份组件快照和第一次生成的快照进行对比如果对组件的结构进行修改那么生成的快照就会对比失败。可以通过更新快照重新进行UI测试。对组件节点进行测试it(should has Button, () { const { wrapper } setup(); expect(wrapper.find(Button).length).toBe(2); }); it(should render 2 item, () { const { wrapper } setupByRender(); expect(wrapper.find(button).length).toBe(2); }); it(should render item equal, () { const { wrapper } setupByMount(); wrapper.find(.item-text).forEach((node, index) { expect(node.text()).toBe(wrapper.props().list[index]) }); }); it(click item to be done, () { const { wrapper } setupByMount(); wrapper.find(Button).at(0).simulate(click); expect(props.deleteTodo).toBeCalled(); });判断组件是否有Button这个组件因为不需要渲染子节点所以使用shallow方法进行组件的渲染因为props的list有两项所以预期应该有两个Button组件。判断组件是否有button这个元素因为button是Button组件里的元素所有使用render方法进行渲染预期也会找到连个button元素。判断组件的内容使用mount方法进行渲染然后使用forEach判断.item-text的内容是否和传入的值相等使用simulate来触发click事件因为deleteTodo被mock了所以可以用deleteTodo方法时候被调用来判断click事件是否被触发。测试组件生命周期//使用spy替身的时候在测试用例结束后要对spy进行restore不然这个spy会一直存在并且无法对相同的方法再次进行spy。 it(calls componentDidMount, () { const componentDidMountSpy jest.spyOn(TodoList.prototype, componentDidMount); const { wrapper } setup(); expect(componentDidMountSpy).toHaveBeenCalled(); componentDidMountSpy.mockRestore(); });使用spyOn来mock 组件的componentDidMount替身函数要在组件渲染之前所有替身函数要定义在setup执行之前并且在判断以后要对替身函数restore不然这个替身函数会一直存在且被mock的那个函数无法被再次mock。测试组件的内部函数it(calls component handleTest, () { // class中使用箭头函数来定义方法 const { wrapper } setup(); const spyFunction jest.spyOn(wrapper.instance(), handleTest); wrapper.instance().handleTest(); expect(spyFunction).toHaveBeenCalled(); spyFunction.mockRestore(); }); it(calls component handleTest2, () { //在constructor使用bind来定义方法 const spyFunction jest.spyOn(TodoList.prototype, handleTest2); const { wrapper } setup(); wrapper.instance().handleTest2(); expect(spyFunction).toHaveBeenCalled(); spyFunction.mockRestore(); });使用instance函数来取得组件的实例并用spyOn方法来mock实例上的内部方法然后用这个实例去调用那个内部方法就可以用替身来判断这个内部函数是否被调用。如果内部方法是用箭头函数来定义的时候需要对实例进行mock如果内部方法是通过正常的方式或者bind的方式定义的那么需要对组件的prototype进行mock。其实对生命周期或者内部函数的测试可以通过一些state的改变进行判断因为这些函数的调用一般都会对组件的state进行一些操作。Manual Mocks对全局的模块(moduleName)进行手动模拟需要在node_modules平级的位置新建一个__mocks__文件夹并在文件夹中新建一个moduleName的文件对某个文件(fileName)进行手动模拟需要在被模拟的文件平级的位置新建一个__mocks__文件夹然后在文件夹中新建一个fileName的文件add/index.js import { add } from lodash; import { multip } from ../../utils/index; export default function sum(a, b) { return add(a, b); } export function m(a, b) { return multip(a, b); }add/__test__/index.test.js import sum, { m } from ../index; jest.mock(lodash); jest.mock(../../../utils/index); describe(test mocks, () { it(test sum, () { expect(sum(2, 3)).toEqual(5); }); it(test mutilp, () { expect(m(2, 3)).toEqual(7); }); });_mocks_:在测试文件中使用mock()方法对要进行mock的文件进行引用Jest就会自动去寻找对应的__mocks__中的文件并进行替换lodash中的add和utils中的multip方法就会被mock成对应的方法。可以使用自动代理的方式对项目的异步组件库(fetch 、axios)进行mock或者使用fetch-mock、jest-fetch-mock来模拟异步请求。对异步方法进行测试async/index.js import request from ./request; export function getUserName(userID) { return request(/users/${userID}).then(user user.name); } async/request.js const http require(http); export default function request(url) { return new Promise((resolve) { // This is an example of an http request, for example to fetch // user data from an API. // This module is being mocked in __mocks__/request.js http.get({ path: url }, (response) { let data ; response.on(data, _data (data _data)); response.on(end, () resolve(data)); }); }); }mock request:const users { 4: { name: hehe, }, 5: { name: haha, }, }; export default function request(url) { return new Promise((resolve, reject) { const userID parseInt(url.substr(/users/.length), 10); process.nextTick(() { users[userID] ? resolve(users[userID]) : reject({ error: User with ${userID} not found., }); }); }); }request.js可以看成是一个用于请求数据的模块手动mock这个模块使它返回一个Promise对象用于对异步的处理。测试Promise// 使用.resolves来测试promise成功时返回的值 it(works with resolves, () { // expect.assertions(1); expect(user.getUserName(5)).resolves.toEqual(haha) }); // 使用.rejects来测试promise失败时返回的值 it(works with rejects, () { expect.assertions(1); return expect(user.getUserName(3)).rejects.toEqual({ error: User with 3 not found., }); }); // 使用promise的返回值来进行测试 it(test resolve with promise, () { expect.assertions(1); return user.getUserName(4).then((data) { expect(data).toEqual(hehe); }); }); it(test error with promise, () { expect.assertions(1); return user.getUserName(2).catch((e) { expect(e).toEqual({ error: User with 2 not found., }); }); });当对Promise进行测试时一定要在断言之前加一个return不然没有等到Promise的返回测试函数就会结束。可以使用.promises/.rejects对返回的值进行获取或者使用then/catch方法进行判断。测试Async/Await// 使用async/await来测试resolve it(works resolve with async/await, async () { expect.assertions(1); const data await user.getUserName(4); expect(data).toEqual(hehe); }); // 使用async/await来测试reject it(works reject with async/await, async () { expect.assertions(1); try { await user.getUserName(1); } catch (e) { expect(e).toEqual({ error: User with 1 not found., }); } });使用async不用进行return返回并且要使用try/catch来对异常进行捕获。代码覆盖率代码覆盖率是一个测试指标用来描述测试用例的代码是否都被执行。统计代码覆盖率一般要借助代码覆盖工具Jest集成了Istanbul这个代码覆盖工具。四个测量维度行覆盖率(line coverage)是否测试用例的每一行都执行了函数覆盖率(function coverage)师傅测试用例的每一个函数都调用了分支覆盖率(branch coverage)是否测试用例的每个if代码块都执行了语句覆盖率(statement coverage)是否测试用例的每个语句都执行了在四个维度中如果代码书写的很规范行覆盖率和语句覆盖率应该是一样的。会触发分支覆盖率的情况有很多种主要有以下几种||if语句switch语句例子function test(a, b) { a a || 0; b b || 0; if (a b) { return a b; } else { return 0; } } test(1, 2); // test();当执行test(1,2)的时候代码覆盖率为当执行test()的时候代码覆盖率为设置阈值stanbul可以在命令行中设置各个覆盖率的门槛然后再检查测试用例是否达标各个维度是与的关系只要有一个不达标就会报错。当statement和branch设置为90的时候覆盖率检测会报当statemen设置为80t、branch设置为50的时候覆盖率检测会通过在Jest中可以通过coverageThreshold这个配置项来设置不同测试维度的覆盖率阈值。global是全局配置默认所有的测试用例都要满足这个配置才能通过测试。还支持通配符模式或者路径配置如果存在这些配置那么匹配到的文件的覆盖率将从全局覆盖率的计算中去除独立使用各自设置的阈值。{ ... jest: { coverageThreshold: { global: { branches: 50, functions: 50, lines: 50, statements: 50 }, ./src/components/: { branches: 40, statements: 40 }, ./src/reducers/**/*.js: { statements: 90, }, ./src/api/very-important-module.js: { branches: 100, functions: 100, lines: 100, statements: 100 } } } }集成到脚手架在项目中引用单元测试后希望每次修改需要测试的文件时能在提交代码前自动跑一边测试用例保证代码的正确性和健壮性。在项目中可以使用husky和lint-staged用来触发git的hooks做一些代码提交前的校验。husky在项目中安装husky以后会在 .git/hooks 中写入 pre-commit 等脚本激活钩子在 Git 进行相关操作时触发lint-staged名字中的staged表示的就是Git中的暂存区它只会对将要加入暂存区中的内容进行lint在package.json中precommit执行lint-staged对lint-staged进行配置对所有的js文件进行eslint 检查对src/components中的js文件进行测试。{ scripts: { precommit: lint-staged, }, lint-staged: { ignore: [ build/*, node_modules ], linters: { src/*.js: [ eslint --fix, git add ], src/components/**/*.js: [ jest --findRelatedTests --config .jest.js, git add ] } }, }对containers中的文件进行修改然后推进暂存区的时候会进行eslint的检查但是不会进行测试对components中的todo-list进行修改eslint会进行检查并且会执行todo-list这个组件的测试用例因为改变了组件的结构所以快照进行UI对比就会失败最后下方这份完整的软件测试 视频教程已经整理上传完成需要的朋友们可以自行领取【保证100%免费】软件测试面试文档我们学习必然是为了找到高薪的工作下面这些面试题是来自阿里、腾讯、字节等一线互联网大厂最新的面试资料并且有字节大佬给出了权威的解答刷完这一套面试资料相信大家都能找到满意的工作。