第一章 集成测试和单元测试
项目中的前端测试,一般包括单元测试和集成测试两种。
1)单元测试
概念:验证代码中的各个独立单元(函数或方法)是否按预期工作。在单元测试中,每个单元都会被隔离测试,以确保其功能正确。
特点:测试覆盖率高、与业务代码耦合度高、代码量大、过于独立
适用场景:单个函数、类等。
2)集成测试
概念:测试多个单元、组件或模块之间的交互,确保不同部分之间能够正常的协作。
特点:测试覆盖率低、耦合度低、代码量小
适用场景:适用于业务逻辑的测试,如一个完整的功能。
单元测试和集成测试是提高代码质量的关键工程实践,有助于早期发现和解决问题,提高代码的可维护性和可靠性。
在前端项目中,如果不依靠任何第三方插件,也可以对项目代码进行测试。我们以函数为例,根据我们目前掌握的知识进行简单测试。
首先假设有一个main.js文件,包含两个方法需要测试:
const sum = (a,b)=>{
return a + b
}
const minus = (a,b)=>{
return a - b
}
然后针对sum和minus方法编写测试用例(至于函数名expect只是为了能更好的引出Jest,其他名字也可以的😊):
function expect(result){
return {
toBe: function (actual){
if(result !== actual){
throw new Error('预期值和实际值不相等');
}
}
}
}
最后执行测试用例:
expect(sum(3,6)).toBe(8);
expect(minus(6,2)).toBe(4);
通过node main.js运行文件来检测用例是否通过。
我们为了使代码结构更清晰,再进一步优化测试用例(这里的test方法也是为了引出Jest,改个名字也可以😜)
function expect(result){
return {
toBe: function (actual){
if(result !== actual){
throw new Error('预期值和实际值不相等');
}
}
}
}
function test(decs,fn){
try{
fn();
}catch(err){
console.log(`${decs}没有通过测试`);
}
}
test('测试加法',()=> expect(sum(3,6)).toBe(9));
test('测试减法',()=> expect(minus(6,2)).toBe(2));
以上就是我们自己写的测试用例,也可以完成对应函数的测试。但是对于过于复杂的组件或函数,自己编写测试用例则显得很吃力。
目前市面上针对前端测试的插件有很多,例如Jest、Jasmine等,本文主要介绍Jest的使用,希望大家可以举一反三。
第二章 Jest入门
1. 项目引入
第一步:下载依赖包 npm install jest -D
第二步:改写package.json文件(多种配置)
"test": "jest" // 当运行npm run test 时执行
"test": "jest --watchAll" // 自动监听所有测试文件,一旦发生改变会自动重新测试
第三步:通过jest导出的API进行测试
// main.test.js
const math = require('./main');
const { sum, minus } = math;
test('测试加法',()=> {
expect(sum(3,6)).toBe(9);
});
test('测试减法',()=> {
expect(minus(6,2)).toBe(4);
});
第四步:执行测试 npm run test
test.only()表示只执行当前的测试用例,省略skipped其他测试用例的执行
2. 使用模块语法
使用Jest进行测试需要基于模块的导出和导入。
- CommonJS模块化:
// 导出
module.exports = {... ...}
// 导入
const xxx = require('./xxx');
- ESModule模块化:
// 导出
export function sum (a,b){
return a + b
}
// 导入
import { sum } from './main';
⚠️:当使用ESModule模块化时,Jest运行会报错“有未知语法错误”。需要搭配babel编译器进行编译,转化为Jest可以识别的CommonJS模块化。
下载babel插件:
npm install @babel/core @babel/preset-env -D
配置babelrc文件:
{
"presets":[
[
"@babel/preset-env",{
"targets":{
"node": "current"
}
}
]
]
}
jest底层集成了一个
babel-jest插件,当运行npm run jest时,babel-jest会检测当前项目是否安装了babel-core依赖,如果安装了,则会从.babelrc文件获取相关配置,并在运行测试代码之前,利用babel-core对当前的代码做一次转换(将ESModule转化为CommonJS),然后再运行转换后的测试用例代码。
3. 初始化配置文件
生成配置文件:npx jest --init
npx jest --[string]可以生成对应的测试覆盖率结果
4. Jest的匹配器
test('测试加法',()=> {
// 测试描述:测试加法
// 测试结果:期待9等于9
expect(9).toBe(9);
});
Jest中的测试用例在使用时,其逻辑类似于。而其中的toBe()就是一种匹配器。
Jest中的匹配器有很多,下面罗列了一些常用的匹配器:
toBe(value):判断两个是否相等,判断逻辑类似于Object.is(NaN等于NaN,+0不等于-0)
test('toBe使用',()=> {
expect(9).toBe(9);
expect('hello').toBe('hello');
expect({a: 1}).toBe({a: 1}); // 测试不通过,引用地址不同
expect(null).toBe(null);
expect(+0).toBe(-0); // 测试不通过
expect(NaN).toBe(NaN);
});
toEqual(value):判断两个引用对象的内容是否相同,对于基本数据类型的判断逻辑同toBe一致
test('测试描述',()=> {
expect({a: 1}).toEqual({a: 1});
expect({a: 1, b: NaN}).toEqual({a: 1, b:NaN});
expect({a: 1, b: -0}).toEqual({a: 1, b: +0}); // 测试不通过,-0不等于+0
expect({a: 1, b: {c: ['1']}}).toEqual({a: 1, b: { c: ['1']}});
});
toBeNull():判断值是否为null
test('测试描述',()=> {
expect(null).toBeNull();
expect(undefined).toBeNull(); // 测试不通过
});
toBeUndefined():判断值是否为undefined
test('测试描述',()=> {
expect(null).toBeUndefined(); // 测试不通过
expect(undefined).toBeUndefined();
});
toBeDefined():判断值是否被定义过,undefined属于未被定义
test('测试描述',()=> {
expect(null).toBeDefined();
expect(undefined).toBeDefined(); // 测试不通过
expect(1).toBeDefined();
});
toBeTruthy():判断值是否为真,会通过JS转换为布尔值
test('测试描述',()=> {
expect(null).toBeTruthy(); // 测试不通过
expect(undefined).toBeTruthy(); // 测试不通过
expect(1).toBeTruthy();
});
toBeFalsy():判断值是否为假,会通过 JS转换为布尔值
test('测试描述',()=> {
expect(null).toBeFalsy();
expect(undefined).toBeFalsy();
expect(1).toBeFalsy(); // 测试不通过
});
.not:对匹配器取相反的值
test('测试描述',()=> {
// 期待null不是假值
expect(null).not.toBeFalsy(); // 测试不通过
// 期待undefined不是假值
expect(undefined).not.toBeFalsy(); // 测试不通过
});
toBeGreaterThan(number | bigint):判断两个数值,前者比后者大
test('测试描述',()=> {
expect(9).toBeGreaterThan(8);
expect(9).toBeGreaterThan(10); // 测试不通过
});
toBeLessThan(number | bigint):判断两个数值,前者比后者小
test('测试描述',()=> {
expect(9).toBeLessThan(8); // 测试不通过
expect(9).toBeLessThan(10);
});
toBeGreaterThanOrEqual(number | bigint):判断两个数值,前者大于等于后者
test('测试描述',()=> {
expect(9).toBeGreaterThanOrEqual(8);
expect(9).toBeGreaterThanOrEqual(10); // 测试不通过
});
toBeLessThanOrEqual(number | bigint):判断两个数值,前者小于等于后者
test('测试描述',()=> {
expect(9).toBeLessThanOrEqual(8); // 测试不通过
expect(9).toBeLessThanOrEqual(10);
});
toBeCloseTo(number,numDigits?):判断两个浮点数是否近似相等,第二个参数是可选的,表示要检查的小数点后位数
test('测试描述',()=> {
expect(0.1 + 0.2).toBeCloseTo(0.3,5);
expect(0.1 + 0.2).toBeCloseTo(0.3,20); // 测试不通过
expect(0.1 + 0.2).toBeEqual(0.3); // 测试不通过,0.3 !== 0.3000000004
});
toMatch(regexp | string):判断字符串是否包含某个字符,参数可以是字符串,也可以是正则表达式
test('测试描述',()=> {
expect('http://www.baidu.com').toMatch('baidu');
expect('http://www.baidu.com').toMatch(/baidu/ig);
expect('http://www.baidu.com').toMatch('souhu'); // 测试不通过
});
toContain(item):判断Array或者Set数据中是否包含某一项item
test('测试描述',()=> {
expect([1,2,3,4]).toContain(4);
expect([1,2,3,4]).toContain(5); // 测试不通过
expect(new Set([1,2,3,4])).toContain(4);
});
toThrow(error?):判断函数运行结果是否抛出异常以及异常信息是否为预期值,异常信息error为可选参数
test('测试描述',()=> {
expect(()=> {
throw new Error('error')
}).toThrow('error11'); // 测试不通过,异常信息不相等
expect(()=> {
throw new Error('error')
}).toThrow('error');
});
5. Jest命令行工具
默认模式下,Jest每次测试都会重新执行所有的test()测试用例,但是当测试用例数量很多时,就会浪费时间和性能。针对此情况,Jest提供了一些命令行指令供用户选择不同测试模式。
f模式:Press f to run only failed tests. 仅会重新测试上一次test时出错的用例。使用时只需要在修改fail test前,按下键盘f键,再次点击可以退出f模式
o模式:Press o to only run tests related to changed files. 相当于jest --watch,仅会重新测试本次修改涉及到的文件中的所有测试用例。使用时只需按下键盘o键,再次点击可以退出o模式
⚠️o模式基于git管理项目代码,因为o模式下需要记录一次修改涉及到的文件列表,而git可以记录文件的修改、新增、删除等操作。
t模式:Press to filter by a test name regex pattern. 根据test name过滤测试用例,只重新执行过滤后的测试用例
p模式:Press p to filter by a filename regex pattern. 根据filename过滤测试用例,只重新执行过滤后的测试用例。
⚠️p模式必须和--watchAll结合使用
6. Jest测试异步代码
1)第一种:有回调函数
import axios from 'axios';
export const fetchData = (callback)=>{
axios.get('http://www.dell-lee.com/react/api/demo.json').then((result)=>{
callback(result.data);
})
}
回调函数的执行属于异步,如果直接进行expect断言则会拿不到执行结果,所以需要将断言的代码执行推迟。test回调函数中的done属性,表示回调函数中的逻辑异步执行。
import { fetchData } from './fetchData';
test('fetchData 返回结果测试',(done)=>{
fetchData((data)=>{
expect(data).toEqual({
success: true
});
done();
})
})
2)第二种:返回promise对象
export const fetchData = ()=>{
return axios.get('http://www.dell-lee.com/react/api/demo.json');
}
- 可以使用
.then进行测试
test('fetchData 返回结果测试',()=>{
return fetchData().then((result)=>{
expect(result.data).toEqual({ success: true })
})
})
- 可以使用
.catch进行测试时,但是需要结合expect.assertions(1)确保当异步代码执行成功时,也会进行catch的测试
test('fetchData 返回结果测试',()=>{
expect.assertions(1);
return fetchData().catch((e)=>{
expect(e.toString().indexOf('404') > -1).toBe(true)
})
})
- 可以使用
.resolves.toMatchObject进行promise对象的测试
test('fetchData 返回结果测试',()=>{
return expect(fetchData()).resolves.toMatchObject({
data:{ success: true }
})
})
- 可以使用
.rejects.toThrow进行promise对象的测试
test('fetchData 返回结果测试',()=>{
return expect(fetchData()).rejects.toThrow()
})
- 可以使用
await/async进行测试,代替done()
test('fetchData 返回结果测试',async ()=>{
await expect(fetchData()).resolves.toMatchObject({
data:{ success: true }
})
})
test('fetchData 返回结果测试',async ()=>{
const result = await fetchData();
return expect(result.data).toMatchObject({ success: true })
})
对于异步代码的测试用例形式多种多样,可以根据项目要求及个人习惯,自行选择。
7. Jest的钩子函数
钩子函数指的是在Jest测试过程中会自动执行的函数,和Vue/React中的生命周期钩子函数含义相同。
beforeAll:所有测试用例执行前触发afterAll:所有测试用例执行完成后触发beforeEach:单个测试用例执行前触发afterEach:单个测试用例执行后触发
beforeAll(()=>{
console.log('beforeAll');
});
beforeEach(()=>{
console.log('beforeEach');
});
afterEach(()=>{
console.log('afterEach');
});
afterAll(()=>{
console.log('afterAll');
});
我们尝试利用钩子函数对class类中的方法进行测试:
export class Counter {
constructor(){
this.number = 0
}
add(){
this.number += 1;
}
minus(){
this.number -= 1;
}
}
import { Counter } from './Counter';
let counter = null;
beforeEach(()=>{
counter = new Counter();
});
test('测试add方法',()=>{
counter.add();
expect(counter.number).toBe(1);
});
test('测试minus方法',()=>{
counter.minus();
expect(counter.number).toBe(-1);
})
Jest提供了一个describe钩子用于对不同模块、不同分类的测试用例进行归纳整理,并在控制台及覆盖率文件中有所体现。
describe('测试Counter相关方法', () => {
let counter = null;
beforeEach(() => {
counter = new Counter();
});
describe('测试加法相关代码',()=>{
test('测试add方法', () => {
counter.add();
expect(counter.number).toBe(1);
});
})
describe('测试减法相关代码',()=>{
test('测试minus方法', () => {
counter.minus();
expect(counter.number).toBe(-1);
})
})
})
每一个describe中都可以嵌套多个钩子函数以及新的describe,内部嵌套的的钩子函数和describe的执行顺序是由外到内执行,而且每一个describe内的钩子函数只对当前describe内的test有效。
describe('第一层',()=>{
beforeAll(()=>{});
test('',()=>{});
describe('第二层-1',()=>{
beforeAll(()=>{});
test('',()=>{});
describe('第三层',()=>{
beforeAll(()=>{});
test('',()=>{});
... ...
})
})
// 第二层-1内的beforeAll对这里不起作用
describe('第二层-2',()=>{
beforeAll(()=>{});
test('',()=>{});
})
})
8. Jest的Mock
8.1 基本的mock
通过Jest.fn()生成一个Mock函数,该函数的执行可以被Jest追踪并捕获到。
Jest.fn()返回的Mock函数的格式大致如下所示:
calls:被调用的次数,[]数组参数表示被调用时传递的参数。如[[1],[2]]则表示一共被调用了两次,第一次传递参数为1,第二次传递参数为2instances:mock函数被调用次数,以及每次调用的this指向invocationCallOrder:mock函数执行的顺序,异步执行还是同步执行。如[1,2,3]则表示共执行三次,每次都是按照顺序执行;[1,3,2]则表示共执行三次,第3次早于第2次执行results:函数类型及返回值
利用该Mock函数可以对项目内编写的回调函数进行测试。
export function demo(callback){
callback();
}
import { demo } from './demo';
test('测试demo函数是否正确执行',()=>{
// 生成mock函数
const func = jest.fn();
// 将mock函数作为参数传递给被测试函数
demo(func);
// 根据mock函数是否执行判断被测试函数的正确性,toBeCalled表示是否被执行
expect(func).toBeCalled();
})
1)可以根据calls属性进行测试
// mock函数是否被调用了一次
expect(func.mock.calls.length).toBe(1);
// mock函数第一次执行时的参数是否为25
expect(func.mock.calls[0]).toEqual([25]);
2)可以根据results属性进行测试
// 设置mock函数返回值
func.mockReturnValue('hello');
// 根据返回值判断mock函数是否正确执行
expect(func.mock.results[0].value).toBe('hello');
对mock函数设置返回值的API包括以下:
mockReturnValue:设置单一返回值,不管调用几次返回值都是固定值mockReturnValueOnce:设置一次的返回值,返回值仅能在单次调用的返回值中使用
func.mockReturnValueOnce('1')
.mockReturnValueOnce('2')
.mockReturnValueOnce('3');
// 第一次调用返回'1'
// 第二次调用返回'2'
// 第三次调用返回'3'
// 第四次调用无返回值,为undefined
‼️ mock函数的作用可以归纳总结为以下几点:
- 捕获函数调用和返回结果、this指向、调用顺序、接收参数
- 自定义函数返回值
- 改变函数内部实现
🚀 mock函数有很多扩展功能的API,以下是部分举例:
- 基于已有函数封装mock函数:
func.mockImplementation(()=>{
// 逻辑设置...
// 返回值设置...
return '1'
})
- 基于已有函数封装单次的mock函数:
func.mockImplementationOnce(()=>{
// 逻辑设置...
// 返回值设置...
return '1'
})
- 设置mock函数返回值为
this
func.mockReturnThis()
8.2 模块的mock
- 第一种:使用
jest.mock自动mock模块
在项目中对于后端接口的测试也可以使用mock函数进行,其优势是不会对接口发起请求,而是改写前端fetch函数内的逻辑,模拟接口并设置返回值,而对于接口能否成功执行的测试应有后端同事进行单元测试。
前端通过axios封装请求接口的方法:
export function getData(){
return axios.get('/api').then((res)=> res.data);
}
通过jest.mock模拟axios内部的逻辑实现,并设置返回值
jest.mock('axios'); // 自动mock axios
test.only('测试getData函数是否正确执行', async()=>{
axios.get.mockResolvedValue({data:'hello'});
await getData().then((data)=>{
return expect(data).toBe('hello');
})
})
- 第二种:
jest.mock()直接在单元测试里面mock模块
jest.mock('fs',()=>{
readFileSync: jest.fn();
})
8.3 类的mock
项目中如果对一个功能很复杂的类如果编写测试用例时,不仅耗费成本,还很耗费性能。为此可以通过mock模拟一个简单的类,从而对类整体或者内部的方法进行单独测试。
假设目前有一个功能很复杂的类:
export default class Utils{
constructor(){
this.number = 0;
}
add(a,b){
return a + b
}
minus(a,b){
return a - b
}
noun(a,b){
return a * b
}
}
1)自动模拟:通过jest.mock([path])模拟类
Jest会将jest.mock([path])语法的执行提升到当前文件的最上方,并且在底层自动对path路径指向的类作如下处理:
- 将类本身转换为mock函数:
const Util = jest.fn() - 将类内部的方法转换为mock函数:
const Util.a = jest.fn()
// 模拟类
jest.mock('./util');
import { demoFunction } from './demo';
import Utils from './util';
test('测试类',()=>{
demoFunction();
// 测试类是否被实例化
expect(Utils).toHaveBeenCalled();
// 测试类的add方法是否被调用
expect(Utils.mock.instances[0].add).toHaveBeenCalled();
// 测试类的minus方法是否被调用
expect(Utils.mock.instances[0].minus).toHaveBeenCalled();
})
2)主动模拟:根目录下创建__mocks__文件夹,并在内部创建模拟文件。主动模拟支持自定义内部逻辑,使用更加灵活
const Utils = jest.fn(()=>{
console.log('Utils');
});
Utils.add = jest.fn(()=>{
console.log('add');
});
Utils.minus = jest.fn(()=>{
console.log('minus');
});
export default Utils;
对于类的测试,并不关心类中某个方法的返回值是否正确,而是关心类是否能被实例化、类中的方法是否能被调用等,所以在测试类时可以考虑使用mock模拟,从而提高性能。
第二章 Jest进阶
1. snapshot快照测试
1.1 toMatchSnapshot()
Jest的toMatchSnapshot用于进行快照测试,首次调用该方法时,会根据函数返回值生成快照并保存到根目录下,以后每次修改函数时都会将函数返回值和快照进行比较,当比较结果不同时,会提示测试不通过以及修改快照等一系列操作提示信息。
snapshot快照测试适合对配置文件等有大量变量的文件进行测试。
我们尝试对下面的函数进行测试:
export function snapTest(){
return {
name: '小明',
age: 18,
sex: "M",
email: "123456789@qq.com",
phone: 15112345678
}
}
import { snapTest } from './snapDemo';
test('测试snapTest',()=>{
expect(snapTest()).toMatchSnapshot();
})
修改snapTest方法,增加一条返回值,test测试将会报错:
school: "北大光华"
可以根据提示信息,切换模式对快照报错进行处理
u模式:直接更新所有出错的快照i模式:进入编辑模式,按照顺序修改出错的快照
对于随时变化的属性值的测试,可以在调用toMatchSnapshot()时传递配置项
// 测试属性time每次test时均不相等
time: new Date()
// 添加配置项
toMatchSnapshot({
time: expect.any(Date) // 匹配任意Date类型数据
})
1.2 toMatchInlineSnapshot()
Jest提供的toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot)会将生成的快照放于当前test()方法内部,所以被称为行内快照。
test("测试snapTest1", () => {
expect(snapTest1()).toMatchInlineSnapshot(`
Object {
"age": 18,
"name": "小明",
"sex": "M",
}
`);
});
2. 定时器timer测试
Jest中针对setTimeout、setInterval等宏任务的测试用例编写,可以通过done()表示异步任务执行完毕,也可以通过mock timer实现。
2.1 常规实现
首先编写setTimeout函数用于被测试:
export function timer(callback){
setTimeout(()=>{
callback();
},3000)
}
然后编写测试用例:
import { timer } from './timer';
test('测试test异步函数',(done)=>{
timer(()=>{
expect(1).toBe(1);
done(); // 内部代码异步执行
})
})
2.2 useFakeTimers
import { timer } from './timer';
// 指定jest在下面的代码中使用假的定时器
jest.useFakeTimers();
test('测试test异步函数',()=>{
// 创建可追踪的函数
const fn = jest.fn();
// 调用函数
timer(fn);
// 立即执行任务队列中所有的任务
jest.runAllTimers();
// 判断mock函数被调用的确切次数
expect(fn).toHaveBeenCalledTimes(1);
});
jest.useFakeTimers()用于指定Jest使用假的定时器,通过jest.fn()创建可追踪的函数。
jest.runAllTimers()指的是执行完所有挂起的宏任务和微任务队列,如果某个任务本身调度了新的任务,那么这些任务将不断被耗尽,直到队列中没有剩余的任务为止。
.toHaveBeenCalledTimes(number)用于断言mock函数被调用的确切次数。
Jest中对于任务队列的执行规则设置有很多种,如下所示:
jest.runOnlyPendingTimers():仅立即执行当前挂起的宏任务jest.runAllTimers():立即执行任务队列中所有任务jest.advanceTimersByTime(msToRun):将当前挂起的下一个宏任务提前一定时间执行,该时间和宏任务的delay应该保持一致。连续调用的advanceTimersByTime,其时间在上一个基础上进行累计
import { timer } from './timer';
jest.useFakeTimers();
test('测试test异步函数',()=>{
const fn = jest.fn();
timer(fn);
// 根据需要测试的setTimeout函数的延迟时间设置delay
jest.advanceTimersByTime(3000);
expect(fn).toHaveBeenCalledTimes(1);
});
3. 操作Dom节点的测试
假设现在有一个函数,每次调用都会在body下面追加一个div标签
import $ from 'jquery';
export const appendDiv = ()=>{
$('body').append('<div />');
}
利用test对该方法进行测试
import $ from 'jquery';
import { appendDiv } from './demo';
test('测试appendDiv方法',()=>{
appendDiv();
// 判断body下div的数量与函数调用次数是否相等
expect($('body').find('div').length).toBe(1);
})
第三章 TDD与BDD
1. TDD
TDD全称为“Test Driven Development”,意为测试驱动的开发。
- 第一步:基于需求,拆分功能,编写多个测试用例
- 第二步:运行测试用例,测试用例无法通过
- 第三步:编写代码,使测试用例通过
- 第四步:优化代码,完成功能开发
- 第五步:重复上述步骤,完成需求开发
TDD的开发流程可以概括为测试用例从红到绿的过程,所以TDD又被称为“Red-Green development”。
TDD测试模式的特点有以下几点:
- 减少项目迭代过程中回归的bug
- 代码质量更高
- 测试覆盖率高
- 错误的测试代码不易出现
2. BDD
BDD全称为“Behavior Driven Development”,意为行为驱动的开发。
BDD编写测试用例时,先开发业务需求和功能,然后再考虑测试用例的编写。要求开发人员站在测试角度去编写测试用例,需要对业务需求和交互逻辑有所掌握。BDD鼓励软件项目中的开发者,QA和非技术人员或商业参与者之间共同协作。
- 第一步:完成需求功能开发
- 第二步:站在用户角度编写测试用例
BDD集成测试特点如下:
- 测试重点在UI(DOM)
- 安全感高
- 速度慢
3. TDD&BDD
⭐️ TDD
- 先写测试再写代码
- 一般结合单元测试使用,属于白盒测试
- 测试重点在代码
- 安全感低(测试用例通过不代表业务逻辑通过)
- 速度快(shallow)
⭐️ BDD
- 先写代码,再写测试
- 一般结合集成测试使用,属于黑盒测试
- 测试重点在UI(DOM)
- 安全感高
- 速度慢(mount)
第四章 Jest在React中的基本使用
1. React环境中配置Jest
通过Create React App安装的React项目中,默认配置了Jest,不需要在单独下载相关依赖。对于Jest的全局配置,可以在package.json文件或者根目录下的jest.config.js文件均可。
React会自动生成一些配置项,对于这些配置项也可以在自己创建的项目中使用。了解每个配置项的含义和作用,有助于我们加深对Jest使用及性能的了解。
collectCoverageFrom:统计项目中哪些文件的测试覆盖率,及项目测试时忽略哪些文件
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}", // 测试js、jsx、tsx后缀的文件
"!src/**/*.d.ts" // 不测试.d.ts后缀的文件
]
setupFiles:测试前的一些准备工作
"setupFiles": [
"react-app-polyfill/jsdom" // 利用polyfill垫片对jsdom进行处理
]
testMatch:运行npm run test时执行的测试文件
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
]
transform:对于某些后缀文件的转换规则
"transform": {
"^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/config/jest/babelTransform.js",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
}
transformIgnorePatterns:忽略对某些后缀文件的转换
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$", // 忽略node包中的文件
"^.+\\.module\\.(css|sass|scss)$"
]
2. Enzyme配置
Enzyme是由Airbnb开发的一个React测试工具,Enzyme在底层对ReactDOM做了封装,并暴露出很多API,可以直接使用Enzyme代替ReactDOM,编写测试用例。
Enzyme具有以下特点:
- 可以模拟组件的渲染和交互
- 支持多种组件的测试,包括函数组件和类组件
- 提供了丰富的选择器,可以帮助我们选择组件中的元素
- 支持快照测试,可以帮助我们检查组件的渲染结果是否正确
首先需要下载安装Enzyme:
npm install Enzyme
添加Enzyme配置:
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
1)shallow:对组件的浅渲染
import Enzyme, { shallow } from 'enzyme';
// 对App组件进行浅渲染
const wrapper = shallow(<App />);
shallow浅渲染只会对当前组件进行渲染测试,对于内层组件不会进行处理,一般用于单元测试。
利用shallow对下面的App组件进行测试:
function App() {
return (
<div className="app-container" title='dell lee'>
hello world !
</div>
);
}
可以对className属性和title属性进行测试:
const wrapper = shallow(<App />);
expect(wrapper.find('.app-container').length).toBe(1);
expect(wrapper.find('.app-container').prop('title')).toBe('dell lee');
⚠️如果在项目中使用className或者style等属性进行测试,则会造成测试和业务代码紧耦合,如果由于业务需求更改了className/style等属性的名称,则会导致测试用例无法通过。所以在项目中可以使用自定义属性[data-xxx=xxx]的方式编写测试用例
expect(wrapper.find('[data-test="container"]').length).toBe(1);
expect(wrapper.find('[data-test="container"]').prop('title')).toBe('dell lee');
💡Enzyme可以结合很多第三方插件来扩展其功能,其中jest-enzyme是针对jest的使用,利用jest-enzyme可以简化很多Enzyme的操作
const wrapper = shallow(<App />);
const container = wrapper.find('[data-test="container"]');
// expect(container.length).toBe(1);
expect(container).toExist();
// expect(container.prop('title')).toBe('dell lee');
expect(container).toHaveProp('title', 'dell lee');
2)mount:对组件的全渲染
mount全渲染会对当前组件及其子组件等都进行渲染测试,一般用于多个组合组件功能的集成测试。
import React from 'react';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import App from './App';
Enzyme.configure({ adapter: new Adapter() });
it('renders without crashing', () => {
const wrapper = mount(<App />);
const container = wrapper.find('[data-test="container"]');
// expect(container.length).toBe(1);
expect(container).toExist();
// expect(container.prop('title')).toBe('dell lee');
expect(container).toHaveProp('title', 'dell lee');
});
3. 基于TDD测试React组件
假设我们的项目需求如下图所示,一个简单的todoList组件。根据业务逻辑将其拆分成Header组件和TodoList组件。
3.1 Header组件测试用例
1)第一步:创建Header.test.js文件,并编写测试用例。
🤔☁️Header组件应该包括一个input输入框
it('组件中包含输入框', () => {
const wrapper = shallow(<Header />); // 单元测试使用浅渲染即可
const inputElem = wrapper.find("[data-test='input']");
expect(inputElem.length).toBe(1);
});
2)第二步:执行测试用例,控制台肯定会报错
3)第三步:编写Header组件内容,添加input输入框
render() {
return (
<div> <input data-test='input'/> </div>
)
}
4)第四步:重新执行测试,显示测试通过
以上就是一个完整的TDD单元测试流程,按照该流程继续完善Header组件的测试用例和功能代码即可。💪📣
🤔☁️Header中的Input组件应该是一个受控组件,具有value属性
// Header.test.js
it('输入框内容初始为空', () => {
const wrapper = shallow(<Header />);
const inputElem = wrapper.find("[data-test='input']");
expect(inputElem.prop('value')).toEqual('');
});
// Header.js
constructor() {
this.state = { value: '' }
}
render() {
const { value } = this.state;
return (
<div>
<input
data-test='input'
value={value}
/>
</div>
)
}
🤔☁️当用户输入时,Header中的Input组件的value值应该跟随变化
// Header.test.js
it('输入框内容随用户输入变化', () => {
const wrapper = shallow(<Header />);
const inputElem = wrapper.find("[data-test='input']");
const userInput = '今天要学习 Jest';
// 利用Enzyme组件库中的API模拟输入框的change事件
inputElem.simulate('change', {
target: { value: userInput}
});
expect(wrapper.state('value')).toEqual(userInput);
});
// Header.js
handleInputChange(e) {
this.setState({
value: e.target.value
})
}
render() {
return (
<div>
<input
data-test='input'
value={value}
onChange={this.handleInputChange}
/>
</div>
)
}
🤔☁️ Input输入框内无内容时,按键盘回车键应该无任何反应
it('输入框无内容时触发回车事件,无反应', () => {
// Header接收父组件传递过来的函数,执行待办事项保存功能
const fn = jest.fn();
const wrapper = shallow(<Header addUndoItem={fn} />);
const inputElem = wrapper.find("[data-test='input']");
// 设置输入框初始值为空字符串
wrapper.setState({ value: ''})
// 模拟输入框的回车事件
inputElem.simulate('keyUp', {
keyCode: 13
});
expect(fn).not.toHaveBeenCalled();
});
🤔☁️ Input输入框内有内容时,按键盘回车键应该执行父组件传递过来的事件
it('输入框有内容被触发时,外部传入的函数被调用', () => {
const fn = jest.fn();
const wrapper = shallow(<Header addUndoItem={fn} />);
const inputElem = wrapper.find("[data-test='input']");
const userInput = '学习 React';
wrapper.setState({ value: userInput})
inputElem.simulate('keyUp', {
keyCode: 13
});
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenLastCalledWith(userInput);
})
3.2 TodoList组件测试用例
🤔☁️ TodoList组件应该具备一个输入列表,并且初始化时应该为空
it('初始化列表为空', () => {
const wrapper = shallow(<TodoList />);
expect(wrapper.state('undoList')).toEqual([]);
});
🤔☁️ TodoList应该给Header传递一个增加undoList内容的方法
it('Header组件存在addUndoItem属性', () => {
const wrapper = shallow(<TodoList />);
const Header = wrapper.find('Header');
expect(Header.prop('addUndoItem')).toBe(wrapper.instance().addUndoItem);
});
🤔☁️ 当Header按回车时,undoList应该新增内容
it('addUndoItem方法被调用, undoList数据项增加', () => {
const wrapper = shallow(<TodoList />);
const Header = wrapper.find('Header');
const addFunc = Header.prop('addUndoItem');
addFunc('学习 React');
expect(wrapper.state('undoList').length).toBe(1);
expect(wrapper.state('undoList')[0]).toBe('学习 React');
addFunc('学习 React 2');
expect(wrapper.state('undoList').length).toBe(2);
});
3.3 TodoListItem组件测试用例
🤔☁️ 初始的未完成列表为空数组,count为0
it('未完成列表当数据为空数组时 count 数目为0, 列表无内容', () => {
const wrapper = shallow(<UndoList list={[]}/>);
const countElem = wrapper.find('[data-test="count"]');
const listItems = wrapper.find('[data-test="list-item"]');
expect(countElem.text()).toEqual("0");
expect(listItems.length).toEqual(0);
});
🤔☁️ 修改后的未完成列表不为空,count大于0
it('未完成列表当数据有内容时count数目显示数据长度, 列表不为空', () => {
const listData = ['学习Jest', '学习TDD', '学习单元测试'];
const wrapper = shallow(<UndoList list={listData}/>);
const countElem = findTestWrapper(wrapper, "count");
const listItems = findTestWrapper(wrapper, "list-item");
expect(countElem.text()).toEqual("3");
expect(listItems.length).toEqual(3);
});
🤔☁️ 在未完成列中点击删除按钮,可以删除单条数据
it('未完成列表当数据有内容时,点击某个删除按钮,会调用删除方法', () => {
const listData = ['学习Jest', '学习TDD', '学习单元测试'];
const fn = jest.fn();
const wrapper = shallow(<UndoList deleteItem={fn} list={listData}/>);
const deleteItems = findTestWrapper(wrapper, "delete-item");
deleteItems.at(1).simulate('click');
expect(fn).toHaveBeenLastCalledWith(1);
});
3.4 对样式采用快照测试
// Header.test.js文件
it('Header 渲染样式正常', () => {
const wrapper = shallow(<Header />);
expect(wrapper).toMatchSnapshot();
});
4. 基于BDD测试React组件
利用BDD思想对项目进行测试时,站在用户的角度,页面中输入框回车增加待办事项属于核心功能,所以我们针对该功能进行测试:
it(`
1. Header 输入框输入内容
2. 点击回车
3. 列表中展示用户输入的内容项
`, () => {
const wrapper = mount(<TodoList />);
const inputElem = wrapper.find('[data-test="header-input"]');
const content = "Dell Lee";
// 模拟input输入框改变事件
inputElem.simulate('change', {
target: {value: content}
});
// 模拟输入框回车事件
inputElem.simulate('keyUp', {
keyCode: 13
});
const listItem = wrapper.find('[data-test="list-item"]');
// 测试list列表数据量和数据内容
expect(listItem.length).toEqual(1);
expect(listItem.text()).toContain(content);
});
5. 基于BDD测试Redux功能
如果使用TDD进行redux的测试,那么action,reducer等都需要编写测试用例,代码量大,也不能保证数据流正确。所以推荐使用BDD来进行Redux的测试。
首先将todoList案例中的数据保存到store中:
function Headers(Props) {
const { onAddItem } = props;
const dispatch = useDispatch();
const [value, setValue] = React.useState("");
return (
<div className="Header">
<input
className="header-input"
type="text"
data-test="input"
value={value}
onChange={(e) => {...}}
onKeyUp={(e) => {...}}
/>
</div>
);
}
function TodoList() {
const { items } = useSelector((state: any) => ({
items: state.todo.items
}));
return (
<div>
<Headers />
{items.map(item => (
<div data-test="list-item" key={item}>{item}</div>
))}
</div>
);
}
然后编写业务逻辑测试用例:
import React from 'react';
import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import TodoList from '../../index';
import { findTestWrapper } from '../../../../utils/testUtils';
import store from '../../../../store/createStore';
it(`
1. Header输入框输入内容
2. 点击回车
3. 列表中展示用户输入的内容项
`, () => {
// 完全渲染组件时需要在外层包裹store
const wrapper = mount(<Provider store={store}><TodoList /></Provider>);
const inputElem = wrapper.find('[data-test="header-input"]');
const content = "Dell Lee";
inputElem.simulate('change', {
target: {value: content}
});
inputElem.simulate('keyUp', {
keyCode: 13
});
const listItem = wrapper.find('[data-test="list-item"]');
expect(listItem.length).toEqual(1);
expect(listItem.text()).toContain(content);
});
对比state和store测试用例的区别,在于对组件渲染时需要包裹Provider并传递store属性
// state
const wrapper = mount(<TodoList />);
// store
const wrapper = mount(
<Provider store={store}><TodoList /></Provider>
);
6. 异步代码的测试
在todoList组件中实现一个功能,在页面初始化的时候请求后台数据并用于展示List。
componentDidMount() {
axios.get('/undoList.json').then((res) => {
this.setState({
undoList: res.data
})
}).catch((e) => {
console.log(e)
})
}
编写测试用例:
🚗第一步:模拟axios
const mockUndoList = {
data: [{
status: 'div',
value: 'dell lee hello'
}],
success: true
};
export default {
get(url) {
if(url === '/undoList.json') {
return new Promise((resolve, reject) => {
resolve(mockUndoList)
});
}
}
}
🚗第二步:编写测试用例
it(`
1. 用户打开页面
2. 请求接口
3. 展示接口返回的数据
`, (done) => {
const wrapper = mount(
<Provider store={store}><TodoList /></Provider>
);
setTimeout(() => {
wrapper.update();
const listItem = findTestWrapper(wrapper, 'list-item');
expect(listItem.length).toBe(1);
done();
});
});
第五章 总结
1. 目录架构优化
以React项目为例,一个React项目一般由react-router、Redux、Jest等生态周边组成,为了使项目目录结构更清晰,测试用例文件编写更方便,可以按照以下结构进行目录设置。
2. 前端自动化测试优点
前端自动化测试的优势有以下几点:
- 更高的组织代码,提高项目的可维护性
- 降低出现Bug的概率,尤其是回归Bug
- 提高修改工程项目的安全性
- 项目具有潜在的文档特性
- 利于扩展开发人员知识面
3. 前端自动化测试分类
- 单元测试:测试单个功能能否正常使用
- 集成测试:测试完整的业务逻辑功能
- 边界测试:测试组件在输入或输出达到极限或边界条件时的行为
- 响应测试:测试UI组件在不同的设备或屏幕尺寸下的行为
- 交互测试:测试UI组件在不同操作下的交互行为
- 异常测试:测试组件在遇到错误或非法输入时能否正确处理
- 性能测试:测试组件的性能