如何做前端单元测试
单元测试
什么是单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证
需要访问数据库的测试不是单元测试
需要访问网络的测试不是单元测试
需要访问文件系统的测试不是单元测试
--- 修改代码的艺术
为什么要做单元测试
- 执行单元测试,就是为了证明这段代码的行为和我们期望的一致
- 进行充分的单元测试,是提高软件质量,降低开发成本的必由之路
- 在开发人员做出修改后进行可重复的单元测试可以避免产生那些令人不快的负作用
怎么去设计单元测试
- 理解这个单元原本要做什么(倒推出一个概要的规格说明(阅读那些程序代码和注释))
- 画出流程图
- 组织对这个概要规格说明的走读(Review),以确保对这个单元的说明没有基本的错误
- 设计单元测试
在实践工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的
两个常用的单元测试方法论:
- TDD(Test-driven development):测试驱动开发
- BDD(Behavior-driven development):行为驱动开发
前端与单元测试
如何对前端代码做单元测试
通常是针对函数、模块、对象进行测试
至少需要三类工具来进行单元测试:
- *测试管理工具
- *测试框架:就是运行测试的工具。通过它,可以为 JavaScript 应用添加测试,从而保证代码的质量
- *断言库
- 测试浏览器
- 测试覆盖率统计工具
测试框架选择
Jasmine:Behavior-Drive development(BDD)风格的测试框架,在业内较为流行,功能很全面,自带 asssert、mock 功能
Qunit:该框架诞生之初是为了 jquery 的单元测试,后来独立出来不再依赖于 jquery 本身,但是其身上还是脱离不开 jquery 的影子
Mocha:node 社区大神 tj 的作品,可以在 node 和 browser 端使用,具有很强的灵活性,可以选择自己喜欢的断言库,选择测试结果的 report
Jest:来自于 facebook 出品的通用测试框架,Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快。他适用但不局限于使用以下技术的项目:Babel, TypeScript, Node, React, Angular, Vue
如何编写测试用例(Jest + Enzyme)
通常测试文件名与要测试的文件名相同,后缀为.test.js
,所有测试文件默认放在__test__
文件夹中
describe
块之中,提供测试用例的四个函数:before()、after()、beforeEach()和 afterEach()。它们会在指定时间执行(如果不需要可以不写)
测试文件中应包括一个或多个describe
, 每个 describe 中可以有一个或多个it
,每个describe
中可以有一个或多个expect
.
describe 称为"测试套件"(test suite),it 块称为"测试用例"(test case)。
expect
就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误.
所有的测试都应该是确定的。 任何时候测试未改变的组件都应该产生相同的结果。 你需要确保你的快照测试与平台和其他不相干数据无关。
基础模板
describe('加法函数测试', () => {
before(() => {
// 在本区块的所有测试用例之前执行
});
after(() => {
// 在本区块的所有测试用例之后执行
});
beforeEach(() => {
// 在本区块的每个测试用例之前执行
});
afterEach(() => {
// 在本区块的每个测试用例之后执行
});
it('1加1应该等于2', () => {
expect(add(1, 1)).toBe(2);
});
it('2加2应该等于4', () => {
expect(add(2, 2)).toBe(42);
});
});
常用的测试
组件中的方法测试
it('changeCardType', () => {
let component = shallow(<Card />);
expect(component.instance().cardType).toBe('initCard');
component.instance().changeCardType('testCard');
expect(component.instance().cardType).toBe('testCard');
});
模拟事件测试
通过 Enzyme 可以在这个返回的 dom 对象上调用类似 jquery 的 api 进行一些查找操作,还可以调用 setProps 和 setState 来设置 props 和 state,也可以用 simulate 来模拟事件,触发事件后,去判断 props 上特定函数是否被调用,传参是否正确;组件状态是否发生预料之中的修改;某个 dom 节点是否存在是否符合期望
it('can save value and cancel', () => {
const value = 'edit';
const { wrapper, props } = setup({
editable: true,
});
wrapper.find('input').simulate('change', { target: { value } });
wrapper.setProps({ status: 'save' });
expect(props.onChange).toBeCalledWith(value);
});
使用 snapshot 进行 UI 测试
it('App -- snapshot', () => {
const renderedValue = renderer.create(<App />).toJSON();
expect(renderedValue).toMatchSnapshot();
});
真实用例分析(组件)
写一个单元测试你需要这样做
- 看代码,熟悉待测试模块的功能和作用
- 设计测试用例必须覆盖到组件的各种情况
- 对错误情况的测试
通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在test文件夹中,一般测试文件包含下列内容:
- 全局设置:一些前置配置,mock 的全局或第三方方法、进行一些重复的组件初始化工作,,当多个测试用例有相同的初始化组件行为时,可以在这里进行挂载和销毁
- UI 测试:为组件打快照,第一次运行测试命令会在目录下生成一个组件的 DOM 节点快照,在之后的测试命令中会与快照文件进行 diff 对照,避免在后面对组件进行了非期望的 UI 更改
- 关键行为:验证组件的基本行为(如:Checkbox 组件的勾选行为)
- 事件:测试各种事件的触发
- 属性:测试传入不同属性值是否得到与期望一致的结果
accordion 组件
// accordion.test.tsx
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import toJSON from 'enzyme-to-json';
import JestMock from 'jest-mock';
import React from 'react';
import { Accordion } from '..';
Enzyme.configure({ adapter: new Adapter() }); // 需要根据项目的react版本来配置适配
describe('Accordion', () => {
// 测试套件,通过 describe 块来将测试分组
let onChange: JestMock.Mock<any, any>; // Jest 提供的mock 函数,擦除函数的实际实现、捕获对函数的调用
let wrapper: Enzyme.ReactWrapper;
beforeEach(() => {
// 在运行测试前做的一些准备工作
onChange = jest.fn();
wrapper = mount(
<Accordion onChange={onChange}>
<Accordion.Item name='one' header='one'>
two
</Accordion.Item>
<Accordion.Item name='two' header='two' disabled={true}>
two
</Accordion.Item>
<Accordion.Item name='three' header='three' showIcon={false}>
three
</Accordion.Item>
<Accordion.Item name='four' header='four' active={true} icons={['custom']}>
four
</Accordion.Item>
</Accordion>
);
});
afterEach(() => {
// 在运行测试后进行的一些整理工作
wrapper.unmount();
});
// UI快照测试,确保你的UI不会因意外改变
test('Test snapshot', () => {
// 测试用例,需要提供详细的测试用例描述
expect(toJSON(wrapper)).toMatchSnapshot();
});
// 事件测试
test('should trigger onChange', () => {
wrapper.find('.qtc-accordion-item-header').first().simulate('click');
expect(onChange.mock.calls.length).toBe(1);
expect(onChange.mock.calls[0][0]).toBe('one');
});
// 关键逻辑测试
//点击头部触发展开收起
test('should expand and collapse', () => {
wrapper.find('.qtc-accordion-item-header').at(2).simulate('click');
expect(wrapper.find('.qtc-accordion-item').at(2).hasClass('active')).toBeTruthy();
});
// 配置disabled时不可展开
test('should not trigger onChange when disabled', () => {
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');
expect(onChange.mock.calls.length).toBe(0);
});
// 对所有的属性配置进行测试
// 是否展示头部左侧图标
test('hide icon', () => {
expect(wrapper.find('.qtc-accordion-item-header').at(2).children().length).toBe(2);
});
// 自定义图标
test('custom icon', () => {
const customIcon = wrapper.find('.qtc-accordion-item-header').at(3).children().first();
expect(customIcon.getDOMNode().innerHTML).toBe('custom');
});
// 是否可展开多项
test('single expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={false} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);
wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');
expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['2']));
});
test('mutiple expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={true} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);
wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');
expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['1', '2']));
});
});
难点记录
对一些异步和延时的处理
使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试
test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
模拟 setTimeout
// 提取utils方法,封装一个sleep
export const sleep = async (timeout = 0) => {
await act(async () => {
await new Promise((resolve) => globalTimeout(resolve, timeout));
});
};
// 测试用例中调用
it('测试用例', async () => {
doSomething();
await sleep(1000);
doSomething();
})
mock 组件内系统函数的返回结果
对于组件内调用了 document 上的方法,可以通过 mock 指定方法的返回值,来保证一致性
const getBoundingClientRectMock = jest.spyOn(
HTMLHeadingElement.prototype,
'getBoundingClientRect',
);
beforeAll(() => {
getBoundingClientRectMock.mockReturnValue({
width: 100,
height: 100,
top: 1000,
} as DOMRect);
});
afterAll(() => {
getBoundingClientRectMock.mockRestore();
});
直接调用组件方法
通过 wrapper.instance()获取组件实例,再调用组件内方法,如:wrapper.instance().handleScroll()
测试系统方法的调用
const scrollToSpy = jest.spyOn(window, 'scrollTo');
const calls = scrollToSpy.mock.calls.length;
expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);
使用属性匹配器代替时间
当快照有时间时,通过属性匹配器可以在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值
it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};
expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});
附录
JEST 语法
匹配器
expect:返回一个'期望‘的对象
toBe:使用 object.is 去判断相等
toEqual:递归检测对象或数组的每个字段
not:测试相反的匹配
真值
toBeNull:只匹配 null
toBeUndefined:只匹配 undefined
toBeDefined:与 toBeUndefined 相反
toBeTruthy:匹配任何 if 语句为真
toBeFalsy:匹配任务 if 语句为假
数字
toBeGreaterThan:大于
toBeGreaterThanOrEqual:大于等于
toBeLessThan:小于
toBeLessThanOrEqual:小于等于
toBeCloseTo:比较浮点数相等
字符串
toMatch:匹配字符串
Array
toContain:检测一个数组或可迭代对象是否包含某个特定项
例外
toThrow:测试某函数在调用时是否抛出了错误
自定义匹配器
// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();
// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);
// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);
// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
测试异步代码
回调
默认情况下,一旦到达运行上下文底部 Jest 测试立即结束,使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试。
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
Promises
为你的测试返回一个 Promise,Jest 会等待 Promise 的 resove 状态,如果 Promist 被拒绝,则测试将自动失败
test('the data is peanut butter', () => {
return fetchData().then((data) => {
expect(data).toBe('peanut butter');
});
});
如果期望 Promise 被 Reject,则需要使用 .catch 方法。 请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则,一个 fulfilled 状态的 Promise 不会让测试用例失败
test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});
.resolves/.rejects
test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});
Async/Await
写异步测试用例时,可以再传递给 test 的函数前面加上 async。
安装和移除
为多次测试重复设置:beforeEach、afterEach 来为多次测试重复设置的工作
一次性设置:beforeAll、afterAll 在文件的开头做一次设置
作用域:可以通过 describe 块将测试分组,before 和 after 的块在 describe 块内部时,则只适用于该 describe 块内的测试
模拟函数
Mock 函数允许你测试代码之间的连接——实现方式包括:擦除函数的实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值。
两种方法可以模拟函数:1.在测试代码中创建一个 mock 函数,2.编写一个手动 mock 来覆盖模块依赖
mock 函数
const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);
// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);
// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);
// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);
// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);
.mock 属性
所有的 mokc 函数都有这个特殊的.mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。.mock 属性还追踪每次调用时的 this 的值,所以我们同样可以检查 this
// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);
// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');
Mock 的返回值
const myMock = jest.fn();
console.log(myMock());
// > undefined
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
模拟模块
可以 用 jest.mock(...)函数自动模拟 axios 模块,一旦模拟模块,我们可为.get 提供一个 mockResolveValue,它会返回假数据用于测试
// users.test.js
import axios from 'axios';
import Users from './users';
jest.mock('axios');
test('should fetch users', () => {
const users = [{ name: 'Bob' }];
const resp = { data: users };
axios.get.mockResolvedValue(resp);
// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))
return Users.all().then((data) => expect(data).toEqual(users));
});
Mock 实现
用 mock 函数替换指定返回值:jest.fn(cb => cb(null, true))
用 mockImplementation 根据别的模块定义默认的 mock 函数实现:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);
当你需要模拟某个函数调用返回不同结果时,请使用 mockImplementationOnce 方法
.mockReturnThis()函数来支持链式调用
Mock 名称
可以为你的 Mock 函数命名,该名字会替代 jest.fn() 在单元测试的错误输出中出现。 用这个方法你就可以在单元测试输出日志中快速找到你定义的 Mock 函数
const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation((scalar) => 42 + scalar)
.mockName('add42');
快照测试
当要确保你的 UI 不会又意外的改变时,快照测试是非常有用的工具;典型的做法是在渲染了 UI 组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者 UI 组件已经更新到了新版本。
快照文件应该和项目代码一起提交并做代码评审
更新快照
jest --updateSnapshot/jest -u
,这将为所有失败的快照测试重新生成快照文件。 如果我们无意间产生了 Bug 导致快照测试失败,应该先修复这些 Bug,再生成快照文件;只重新生成一部分的快照文件,你可以使用--testNamePattern
来正则匹配想要生成的快照名字
属性匹配器
项目中常常会有不定值字段生成(例如 IDs 和 Dates),针对这些情况,Jest 允许为任何属性提供匹配器(非对称匹配器)。 在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值
it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};
expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});
覆盖率
Jest 还提供了生成测试覆盖率报告的命令,只需要添加上 --coverage 这个参数即可生成,再加上--colors 可根据覆盖率生成不同颜色的报告(<50%红色,50%~80%黄色, ≥80%绿色)
- % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了
- % Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if, ||, ? : )
- % Funcs 函数覆盖率(function coverage):是否每个函数都调用了
- % Lines 行覆盖率(line coverage):是否每一行都执行了
Enzyme
nzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。Enzyme 兼容所有的主要测试运行器和判断库。
安装与配置
- npm install --save-dev enzyme
- 安装 Enzyme Adapter 来对应 React 的版本 npm install --save-dev enzyme-adapter-react-16
渲染方式
shallow 浅渲染
返回组件的浅渲染,对官方 shallow rendering 进行封装。浅渲染 作用就是:它仅仅会渲染至虚拟 dom,不会返回真实的 dom 节点,这个对测试性能有极大的提升。shallow 只渲染当前组件,只能能对当前组件做断言
render 静态渲染
将 React 组件渲染成静态的 HTML 字符串,然后使用 Cheerio 这个库解析这段字符串,并返回一个 Cheerio 的实例对象,可以用来分析组件的 html 结构,对于 snapshot 使用 render 比较合适
mount 完全渲染
将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境
常用 API
.simulate(event, mock):用来模拟事件触发,event 为事件名称,mock 为一个 event object
.instance():返回测试组件的实例
.find(selector):根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 display name 等
.get(index):返回指定位置的子组件的 DOM 节点
.at(index):返回指定位置的子组件
.first():返回第一个子组件
.last():返回最后一个子组件
.type():返回当前组件的类型
.contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为 react 对象或对象数组
.text():返回当前组件的文本内容
.html():返回当前组件的 HTML 代码形式
.props():返回根组件的所有属性
.prop(key):返回根组件的指定属性
.state([key]):返回根组件的状态
.setState(nextState):设置根组件的状态
.setProps(nextProps):设置根组件的属性
链接:https://juejin.cn/post/6990655486659919902