通过上一篇文章 前端自动化测试 - 入门篇,相信大家对与前端自动化测试有了一个初步的认识和了解,这一节,我们将深入了解其中的单元测试,以Jest框架为例,从理论到实践全面介绍单元测试(看完这篇,再也不用担心面试官问关于单元测试的问题啦)。
基本介绍和配置
Jest 是 Facebook 出品的一个 JavaScript 开源测试框架。相对其他测试框架,其一大特点就是就是内置了常用的测试工具,比如零配置、自带断言、测试覆盖率工具等功能,实现了开箱即用。
Jest 适用但不局限于使用以下技术的项目:Babel,、TypeScript、 Node、 React、Angular、Vue 等。
Jest 主要特点:
- 零配置
- 自带断言
- 而作为一个面向前端的测试框架, Jest 可以利用其特有的快照测试功能,通过比对 UI 代码生成的快照文件,实现对 React 等常见前端框架的自动测试。
- 此外, Jest 的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,提升了测试速度。
- 测试覆盖率
- Mock 模拟
这些特点,大家简单了解即可,后续的实践会有一个更深入的介绍和使用。
项目初始化
- 创建项目
mkdir jest-demo
cd jest-demo
npm init
- 安装jest
npm i jest --save-dev
- 执行测试命令 在package.json中配置jest命令
// package.json
// ...
scripts: {
"test": "jest --watchAll",
}
然后执行 npm run test 即可对所有以 *.test.js结尾的文件进行测试。
设置Jest配置
我们有两种方式去设置jest配置:
- 通过jest --init单独生成jest配置文件
- 通过 cli 命名行参数配置。
单独生成配置文件
jest --init
执行该命令,可以生成jest.config.js配置文件,我们可以对其中的一些属性进行全局修改。
使用 CLI选项
即除了可以通过配置文件去配置相关属性外,我们也可以直接在执行命令的时候,指定相关参数。例如:
npx jest 01/demo.test.js --watchAll // 测试指定文件
这里我们说明一下:-watchAll 属性
jest --watchAll // 直接监视所有文件
jest --watch // 需要和git配合使用,也就是说只会监视git中已修改且未添加到暂存区的文件,
运行结果如下:
除此之外,还要注意一下,上图中的一些额外的监视选项: f,o等。 其实就是为了更快捷的监听文件的变化,在实际开发中时候之后即可掌握。
使用ES6模块
默认情况下,在.test.js的测试文件中,只能通过commonJS规范去引入要测试的模块,如果我们想使用ES6模块,则需要额外做一些配置。
- 安装babel相关依赖
npm i babel-jest @babel/core @babel/preset-env --save-dev
- 添加babel.config.js
// babel.config.js
module.exports = {
presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};
然后,我们就可以在.test.js测试脚本中使用ES6模块啦。
Jest 结合 Babel 的运行原理:运行测试之前,结合 Babel,先把你的代码做一次转化,模块被转换为了 CommonJS,运行转换之后的测试用例代码。 具体流程如下:
- npm run jest
- jest
- babel-jest
- babel-core
- babel 配置文件
- 转换为 ES5
- 执行转换之后的测试代码
初步体验
首先,我们通过一个简单例子直观的感受一下,如何去通过Jest写单元测试。
例如:我们定义了sum和subtract两个函数,如何给它们添加写测试用例呢?
// demo.js
function sum(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = {
sum,
subtract,
};
// demo.test.js
const { sum, subtract } = require("./demo");
test("sum: ", () => {
expect(sum(1, 2)).toBe(3);
});
test("subtract: ", () => {
expect(subtract(1, 2)).toBe(-1);
});
此时,要测试的函数以及测试脚本都已经写好,接下来,我们需要安装jest依赖。
npm i jest --save-dev
然后,配置script运行脚本
{
"scripts": {
"test": "jest"
},
//...
}
执行 npm run test,此时jest就会自动去寻找当前项目下所有以.test.js结尾的文件,并且执行测试脚本。
注意:我们在测试脚本中并没有手动去引入jest,而是直接使用test,expect等方法,这是因为在执行jest命令时,会自动将这些方法注入到.test.js文件中,不需要我们手动引入
。
此时,可能会有疑问,不手动引入依赖包,那我们调用其API的智能提示不就没有啦,确实没有啦,这时,我们可以安装下面的依赖来解决智能提示的问题
npm i @types/jest --save-dev // 注意必须安装到项目根目录下。
全局API
jest对象
我们安装jest之后,执行 jest 命令,被执行的测试文件会自动创建jest全局对象。
即jest
对象在每个测试文件的作用范围内. jest
对象中的方法帮助创建模型并让你控制Jest的整体行为。 也可以通过 import {jest} from '@jest/globals'
引入。
jest对象内部属性,我们可以直接参考 官网-jest对象 ,这里暂时不展开说啦,后面我们会根据实际例子对其中一些常用属性详细说明。
test函数
test函数,是单元测试的核心,它可以帮助我们创建一个测试用例,而单元测试就是由一个个的测试用例所组成的。
test('对要测试的功能点说明', () => {
// ...
})
expect函数
这里会有一个新的名词:断言
,其实就是判断实际运行的结果是否符合我们预期的结果,expect函数,顾名思义期望的意思。既然是对已有代码进行测试,其实就是把实际运行的结果与我们期望的结果进行比较,如果一致,就说明测试通过。
expect(1).toBe(1);
describe 函数
通过test函数可以创建一个个的测试用例,那当我们的测试用例越来越多的时候,就需要对测试用例进行分类整理,那这就是describe函数的作用。
describe('测试1', () => {
test('测试用例1-1', () => {
//...
})
test('测试用例1-2', () => {
//...
})
})
describe('测试2', () => {
test('测试用例2-1', () => {
//...
})
test('测试用例2-2', () => {
//...
})
})
在实际开发中,describe和test都是我们的必用函数。
生命周期钩子函数
顾名思义,就是在每个测试用例执行前后,我们会有一些钩子函数,从而用来做一些拦截性的工作。
beforeAll // 所有测试用例执行之前执行,只执行一次
beforeEach // 每个测试用例执行之前都会执行,可能执行多次
afterEach // 每个测试用例执行之后都会执行,可能执行多次
afterAll // 所有测试用例执行之后执行,只执行一次。
除此,之外,这些钩子函数,也可以在describe函数之中使用。
// counter.js
export default class Counter {
constructor() {
this.number = 0;
}
addOne(){
this.number += 1;
}
addTwo(){
this.number += 2;
}
minusOne() {
this.number -= 1;
}
minusTwo() {
this.number -= 2;
}
}
// test.counter.js
import Counter from './counter'
let counter = null;
beforeAll(() => {
console.log('beforeAll')
})
afterAll(() => {
console.log('afterAll');
})
beforeEach(() => {
console.log('beforeEach')
counter = new Counter();
})
afterEach(() => {
console.log('afterEach')
})
describe('测试 add 相关代码', () => {
beforeAll(() => {
console.log('beforeAll add test')
})
afterAll(() => {
console.log('afterAll add test');
})
beforeEach(() => {
console.log('beforeEach add test')
})
afterEach(() => {
console.log('afterEach add test')
})
test.only('测试 Counter 中的 addOne 方法', () => {
console.log('测试 Counter 中的 addOne 方法')
counter.addOne();
expect(counter.number).toBe(1);
})
test('测试 Counter 中的 addTwo 方法', () => {
console.log('测试 Counter 中的 addTwo 方法')
counter.addTwo();
expect(counter.number).toBe(2);
})
})
describe('测试 minus 相关代码', () => {
test('测试 Counter 中的 minusOne 方法', () => {
console.log('测试 Counter 中的 minusOne 方法');
counter.minusOne();
expect(counter.number).toBe(-1);
})
test('测试 Counter 中的 minusTwo 方法', () => {
console.log('测试 Counter 中的 minusTwo 方法');
counter.minusTwo();
expect(counter.number).toBe(-2);
})
})
以上代码中第一个测试用例执行顺序如下:
这里要有两点需要额外注意:
- 如果我们暂时希望只执行当前测试用例,这时可以使用test.only()
生命周期的钩子函数是有作用域的
,即只对生命周期钩子所在的describe内的测试用例有效。其他describe中的测试用例无法使用这些钩子函数。
常用matcher
即jest为我们提供了很多的匹配器API,可以帮助我们更快速的去断言程序的结果是否符合我们的预期。
这里,我们针对不同的数据类型做了分类整理:
布尔值
- toBeNull
- toBeDefined
- toBeUndefined
- toBeTruthy
- toBeFalsy
test('null', () => {
const n = null;
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).not.toBeUndefined();
expect(n).not.toBeTruthy();
expect(n).toBeFalsy();
});
字符串
- toMatch()
test('测试字符串', () => {
expect('hello world').toMatch(/world/);
});
数值
- toBe
- toEqual:等于
- toBeGreaterThan:大于
- toBeGreaterThanOrEqual:大于等于
- toBeLessThan:小于
- toBeLessThanOrEqual:小于等于
- toBeCloseTo:浮点数比较
test('测试数值', () => {
const value = 4;
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(4);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(5);
expect(value).toBe(4);
expect(value).toEqual(4);
expect(0.1 + 0.2).toBeCloseTo(0.3);
})
数组和集合
- toContain
test('测试数组和集合', () => {
const arr = ['kobe', 'james', 'durant']
expect(arr).toContain('kobe');
expect(new Set(arr)).toContain('kobe');
})
异常
- toThrow
test('测试异常', () => {
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrow('this is a error');
expect(() => fn()).toThrow(/error/);
})
以上是一些常用匹配器,更多的匹配器,可以参考:官网-匹配器
测试异步代码
之前在初步体验中,被测函数是一个简单的同步函数,有明确的输入和输出,这种我们写起来很简单,但是在平时开发中,很显然不可能这么简单,其中也肯定会遇到各种异步代码,例如:定时器,ajax请求等,
那如何测试异步代码呢?这里我们分为两种情况:
- callback形式的异步代码
- promise形式的异步代码
callback
- 首先我们来看一下,通过callback回调的方式执行的异步代码。
// fetchData.js
import axios from 'axios';
export const fetchData = (fn) => {
return axios.get('http://www.dell-lee.com/react/api/demo1.json').then(response => {
fn(response.data)
})
}
// fetchData.test.js
import {fetchData} from './fetchData';
// 错误写法:这个测试用例不管接口有没有报错都会正常通过,因为传入的回调函数本身就没有执行。
test('fetchData 返回结果为 {success: true}', () => {
fetchData((data) => {
expect(data).toEqual({success: true});
})
})
// 正确写法:需要在回调函数中手动调用done(),表示该回调函数执行以后,用例才算通过。
test('fetchData 返回结果为 {success: true}', (done) => {
fetchData((data) => {
expect(data).toEqual({success: true});
done();
})
})
总结: 核心就是需要给test的回调函数参数传入done, 然后手动执行。
Promise
- 我们在来看一下,通过promise的方式执行的异步代码
// fetchData.js
import axios from 'axios';
export const fetchData = (fn) => {
return axios.get('http://www.dell-lee.com/react/api/demo1.json');
}
// 写法1: 在then或者catch回调函数中进行断言,注意一定要return。
test('fetchData 返回结果为 {success: true}', () => {
return fetchData().then((resp) => {
expect(resp.data).toEqual({success: true});
})
})
test('fetchData 返回结果为 404', () => {
return fetchData().catch((e) => {
expect(e.toString().indexOf('404') > -1).toBe(true);
})
})
// 写法2: 使用await/try...catch
test('fetchData 返回结果为 {success: true}', async () => {
const resp = await fetchData();
expect(resp.data).toEqual({success: true});
})
test('fetchData 返回结果为 404', async () => {
try {
const resp = await fetchData();
} catch(e) {
expect(e.toString().indexOf('404') > -1).toBe(true);
}
})
// 写法3: 使用jest自带属性:resolves和rejests
test('fetchData 返回结果为 {success: true}', () => {
return expect(fetchData()).resolves.toMatchObject({
data: {
success: true
}
})
})
test('fetchData 返回结果为 404', () => {
return expect(fetchData()).rejects.toThrow();
})
总结:
- 使用第一种写法,在then或者catch回调函数中进行断言时,一定要注意return。
- 使用第二种写法,正常情况很简洁,但是异常情况,需要手动进行try...catch。
- 使用第三种写法:写法比较简洁,但是需要熟练jest自带这些属性的用法。
Mock
Mock定时器
在实际写测试用例的时候,如果有一些比较耗时的代码,比如定时器,在对这些代码进行测试的时候,其实没必要按照实际设定的时间进行运行,因此,可以对定时器进行Mock。
如果想使用mock定时器,就必须要在文件头部声明:jest.useFakeTimers(),声明完之后,还需要我们去调用一些API去mock定时器已经快速执行完,如何mock定时器的执行呢?
- 使用 jest.runAllTimers 与 jest.runOnlyPendingTimers() 表示快速执行所有定时器或者处于消息队列中的定时器。
- 使用jest.advanceTimersByTime() 表示快进多长时间执行,比如定时器设置了3s后执行,我们就可以调用该方法,传入3s,就表示定时器立即执行。
jest.runAllTimers()
例如:测试代码如下:
// index.js
export const getData = (callback) => {
setTimeout(() => {
callback({
name: 'kobe'
})
},3000)
}
export default getData;
于是,我们很容易写出如下的测试用例:
import {getData} from './index';
test('测试 getData', (done) => {
getData((data) => {
expect(data).toEqual({name: 'kobe'});
done();
})
})
以上写法是完全可以正常运行的,但是有一个问题就是它是按照实际定时器设置的时间去执行的,也就意味着我们需要等3000毫秒,因此也就出现了mock定时器,这样我们就可以不用按照实际定时器设置的时间执行啦。
那如何mock定时器呢?
import {getData} from './index';
jest.useFakeTimers();
test('测试 getData', () => {
const fn = jest.fn();
getData(fn);
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(1);
})
注意:需要jest.useFakeTimers() 和 jest.runAllTimers()搭配使用,前者表示当前测试用例使用的都是mock定时器,后者表示执行所有的定时器。
jest.advanceTimersByTime() - 推荐
我们更推荐使用这个API去mock定时器。那我们基于上面的例子进一步体会一下:
// index.js 嵌套了两个定时器
export const getData = (callback) => {
setTimeout(() => {
callback();
setTimeout(() => {
callback();
}, 3000)
},3000)
}
export default getData;
我们 如果依然采用 jest.runAllTimers ,则会写出如下代码:
import {getData} from './index';
jest.useFakeTimers();
test('测试 getData', () => {
const fn = jest.fn();
getData(fn);
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(2); // 很显然执行了两次
})
或者也可以采用 jest.runOnlyPendingTimers,代码如下:
import {getData} from './index';
jest.useFakeTimers();
test('测试 getData', () => {
const fn = jest.fn();
getData(fn);
jest.runOnlyPendingTimers();
expect(fn).toHaveBeenCalledTimes(1); // 很显然此时只执行了一次
})
那我们如果采用jest.advanceTimersByTime,代码如下:
import {getData} from './index';
jest.useFakeTimers();
test('测试 getData', () => {
const fn = jest.fn();
getData(fn);
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(1); // 快进3s,执行第一个定时器的回调
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(2); // 再快进3s,执行第二个定时器的回调
})
但是 jest.advanceTimersByTime() 有一个什么问题呢? 每次调用该API都是在上一步快进时间的基础上进行快进。多个测试用例之间也是这样,那如何解决测试用例之间的相互影响呢?
import {getData} from './index';
// 即每个测试用例都使用一个mock定时器,保证不会相互影响。
beforeEach(() => {
jest.useFakeTimers();
})
test('测试 getData', () => {
const fn = jest.fn();
getData(fn);
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(2);
})
Mock函数
在实际的开发中,被测试代码很可能不是一个简单的有明确输入输出关系的代码,那很可能是包含各种嵌套,回调,异步代码等,那我们在这些情况下,我们就需要通过mock函数的形式,其实本质上就是jest内部生成了一个函数,同时jest内部可以监控到这个函数的调用情况,从而帮助我们进行断言。
通过jest创建一个mock函数,该mock的函数体,返回值等我们都可以自由定义,当该mock函数被调用之后,我们也可以通过jest提供的内部属性,捕获到该mock函数被调用的次数,参数情况等。
基本使用
因此,mock函数通常可以实现以下这些功能:
捕获函数的调用情况,以及返回值,this指向以及调用顺序
它可以让我们自由设置 函数的返回值。
可以改变函数的内部实现
接下来,我们就通过实际代码学习一下:
-
捕获函数的调用情况,以及返回值,this指向以及调用顺序
test.only('测试 mock 函数', () => { const func = jest.fn(); func(123); console.log(func.mock); // 断言:函数是否被调用 expect(func).toBeCalled(); // 断言:函数被调用次数 expect(func.mock.calls.length).toBe(1); // 断言:函数参数 expect(func.mock.calls[0]).toEqual([123]) })
我们看一下func.mock的打印结果:
从图中我们可以看到:
- calls:可以看到函数被调用的次数,以及每次被调用时所传的参数,
- instances属性:可以看到函数被调用时,函数内部的this指向。
- invocationCallOrder:可以看到函数被调用的顺序,此处只调用了一次。
- results:可以看到函数每次调用的返回值是什么。
- 它可以让我们自由设置 函数的返回值。
test.only('测试 mock 函数', () => {
const func = jest.fn();
func.mockReturnValueOnce('kobe');
func();
expect(func.mock.results[0].value).toBe('kobe');
});
除此之外,还有几点细节要注意:
- 如果想在每次函数调用之后,设置不同的返回值,可以多次调用func.mockReturnValueOnce()。
- 如果每次调用返回相同的值,可以只调用一次func.mockReturnValue()
- 可以改变函数的内部实现
注意:同理,也可以通过 func.mockImplementationOnce 设置某一次调用对应的函数实现,可以针对不同的调用,设置不同的函数实现。test.only('测试 mock 函数', () => { const func = jest.fn(); func.mockImplementationOnce(() => { return 'kobe'; }) func(); expect(func.mock.results[0].value).toBe('kobe') });
实战使用
上面,我们只是简单的通过jest.fn()直接创建了一个mock函数,并且进行调用,但是并没有真正的被测试代码,因此,本小节就是从一个实际例子出发,来进一步体会一下,如何去使用mock函数。
场景:我们要为数组的遍历函数:forEach写测试用例,该如何做呢?
// array.js
export const forEach = (arr, callback) => {
for (let i = 0; i < arr.length; i++) {
callback(arr[i], i);
};
}
// array.test.js
test.only('测试 forEach 函数', () => {
const arr = [1, 2, 3, 4];
const func = jest.fn();
func.mockImplementation((item, index) => {
return item + 1;
})
forEach(arr, func);
// 断言:函数被调用次数
expect(func.mock.calls.length).toBe(arr.length);
// 断言:函数返回值
expect(func.mock.results[0].value).toBe(2);
})
重点说明:mock函数使用方法其实很简单,记住这些常用的api即可,更重要的是我们要明白什么时候需要使用mock函数,当被测函数只是一个简单函数,有明确输入输出时,我们直接调用,然后断言其返回值即可,但是当被测函数内部比较复杂,可能涉及循环,异步逻辑,回调函数等场景时,通过就需要我们创建mock函数,并且将mock函数传入被测函数内部,调用被测函数,其内部也就会自动调用mock函数,从而我们就可以通过读取mock函数的mock属性,来获取该mock函数的调用次数,返回值等信息,从而判断被测函数是否正常。
Mock模块
Mock第三方模块
例如:我们发送请求时,通常会用到axios,但是我们又不想走真实的请求,所以就可以mock一个axios,然后就可以自由设置请求的返回值是什么。
// index.js
import axios from "axios"
export const getData = () => {
return axios.get('http://www.dell-lee.com/react/api/demo1.json').then(resp => resp.data);
}
// index.test.js
import axios from 'axios';
import {getData} from './index';
jest.mock('axios');
test('测试 getData 方法', async () => {
axios.get.mockResolvedValue({
data: {
name: 'kobe'
}
})
const data = await getData();
expect(data).toEqual({name: 'kobe'});
})
mock模块部分实现 - 写法1
即一个模块中,可能有很多个方法,有同步方法,有异步方法,同步方法我们一般不需要模拟,但是异步方法,我们一般就需要一个对应的模拟方法,从而保证测试代码执行的之后,走的是我们的模拟方法,并不需要真正发起请求。
// index.js
import axios from "axios"
export const getData = () => {
return axios.get('http://www.dell-lee.com/react/api/demo.json').then(resp => resp.data);
}
export const sum = (a, b) => {
return a + b;
}
该模块中,有两个方法,由于getData需要发起异步请求,而我们又不希望测试代码中就要发送真实的模拟请求,所以需要创建一个对应的mock函数,而sum方法比较简单,一个同步函数,直接引入测试即可。
注意:如果我们希望对某个模块进行mock,则可以在其同级目录下创建一个__mocks__文件夹,并且在改文件夹下创建同名的文件。
// __mocks__/index.js
export const getData = () => {
return new Promise((resolve, reject) => {
resolve({
name: 'james'
})
})
}
接下来,我们就要写测试用例啦,由于我们要对index.js模块进行mock,所以需要手动调用jest.mock(./index),表示测试用例执行时走的是mock的函数。
jest.mock('./index')
import {getData, sum} from "./index";
test('测试 getData 方法', async () => {
const res = await getData();
expect(res).toEqual({
name: 'james'
})
})
// 此时该用例无法通过,因为走的是mock模块,而mock模块中并没有sum函数。
test('测试 sum 方法', async () => {
expect(sum(1, 2)).toBe(3);
})
以上代码,由于我们统一走的都是mock模块,但是我们在测试sum函数时,又希望走真实模块,所以以上代码中的第二个测试用例是通过不了的,需要进行如下修改:
jest.mock('./index')
import { getData} from "./index";
const {sum} = jest.requireActual('./index.js') // 关键点
test('测试 getData 方法', async () => {
const res = await getData();
expect(res).toEqual({
name: 'james'
})
})
test('测试 sum 方法', async () => {
expect(sum(1, 2)).toBe(3);
})
mock模块部分实现 - 写法2
写法1中,我们在被测模块的同级目录下,创建了一个__mocks__文件夹,并且在该文件下创建了同名的mock模块。当然,其实我们也可以直接在测试文件中去mock模块中的部分方法。
jest.mock('./index', () => {
return {
getData: jest.fn(() => {
return new Promise((resolve, reject) => {
resolve({
name: 'james'
})
})
})
}
})
import { getData} from "./index";
const {sum} = jest.requireActual('./index.js')
test('测试 getData 方法', async () => {
const res = await getData();
expect(res).toEqual({
name: 'james'
})
})
test('测试 sum 方法', async () => {
expect(sum(1, 2)).toBe(3);
})
以上写法,可以其实可以进一步优化一下
jest.mock('./index', () => {
// 其实就是先引入全部真实模块中的全部方法,然后解构,需要进行mock的函数直接覆盖即可。
const originalModule = jest.requireActual('./index');
return {
...originalModule,
getData: jest.fn(() => {
return new Promise((resolve, reject) => {
resolve({
name: 'james'
})
})
})
}
})
import { getData} from "./index";
test('测试 getData 方法', async () => {
const res = await getData();
expect(res).toEqual({
name: 'james'
})
})
test('测试 sum 方法', async () => {
expect(sum(1, 2)).toBe(3);
})
Mock 类
例如:我们有一个Util类,如下:
// util.js
class Util {
a () {}
b() {}
}
export default Util
当然,它也有对应的一个测试文件。
// util.test.js
import Util from './util'
let util = null;
beforeAll(() => {
util = new Util();
})
test('测试 Util a方法', () => {
expect(util.a()).toBeUndefined();
})
test('测试 Util b方法', () => {
expect(util.b()).toBeUndefined();
})
很显然,如果我们想对一个类中的方法进行测试,其实很简单,但是上面的代码不是我们这里要说的重点,我们要说的场景是:当其他文件中引用了Util中的方法时,我们该如何对其进行测试?
例如:
// demo.js
import Util from './util'
export const demoFunction = () => {
const util = new Util();
util.a();
util.b();
}
我们想要对demoFunction进行测试,那如何写测试用例呢?写之前,我们要明白:对demoFunction方法进行测试,虽然它内部引用了Util类相关的方法,但是我们并不需要对Util中的方法再进行测试,因为我们在util.test.js中已经对Util类中的方法进行了测试,此处demoFunction我们只需要判断Util中的方法是否被调用即可。
因此:我们就可以Mock一个类,从而保证Mock类中的方法被调用了即可。 实现如下:
jest.mock('./util.js') //关键点
import Util from './util'
import {demoFunction} from './demo'
test('测试 demoFunction', () => {
demoFunction();
expect(Util).toHaveBeenCalled();
expect(Util.mock.instances[0].a).toHaveBeenCalled();
expect(Util.mock.instances[0].b).toHaveBeenCalled();
})
说明:当我们声明了jest.mock('./util.js') 之后,jest会自动将导入的Util变成一个mock函数,并且其内部的方法也都会自动变成mock函数,从而,我们就可以直接使用mock函数的特性,来判断函数是否被调用。
我们可以打印一下:Util.mock, 输入结果如下:
说明:其实我们之前学到的mock第三方模块,例如:axios,其实本质上就是mock了一个类,然后我们就可以调用mock类内部的方法
。再通过代码来体会一下:
jest.mock('axios')
import axios from 'axios';
test('测试 request方法', () => {
console.log(axios.get.mockReturnValue({name: 'kobe'}));
})
同时,通过前面的学习,我们也可以手动创建__mock__文件夹,以及同名的文件,去对Util中的方法进行自定义的mock。
因此,对类进行mock其实是四种方式:- 通过jest.mock('./utils.js') 自动模拟
- 通过创建__mocks__ 同名文件,手动模拟
- 也可以不单独创建文件,直接在jest.mock(',/util.js', () => {}) 即第二个参数中,手动返回mock函数。
- 当然,我们某个测试用例中,单独使用mockImplementation 模拟某个函数的实现。
Dom测试
在jest内部集成了Jsdom依赖包,所有我们也可以直接使用Jsdom相关API去操作dom。
我们来实际体验一下:
function renderHtml() {
const div = document.createElement("div");
div.innerHTML = "<h1>Hello World</h1>";
document.body.appendChild(div);
}
test("DOM Test:", () => {
renderHtml();
expect(document.querySelector("h1").innerHTML).toBe("Hello World");
});
快照测试
顾名思义,当我们的组件已经完成或者不希望更改时,我们可以将写好的组件或者dom结构生成一份快照,其实就是一个html字符串,之后如果无意间修改了dom结构,这时快照测试,就会提示我们两次生成的快照不一致,从而进一步判断以哪个为准。
我们来体验一下:
function renderHtml() {
const div = document.createElement("div");
div.innerHTML = "<h1>Hello World1</h1>";
document.body.appendChild(div);
}
test("DOM Test:", () => {
renderHtml();
expect(document.querySelector("h1").innerHTML).toBe("Hello World");
});
test("Snapshot Test:", () => {
expect(document.body).toMatchSnapshot(); // 首次执行会生成快照文件。
});
之后,如果我们想要更新快照文件,当然也是可以实现的,执行以下命令即可
jest --updateSnapshot
测试覆盖率
测试覆盖率就是执行过的代码占总代码的比例,比如执行了多少行(Line),执行了多少个分支(Branch),执行了多少个函数(Function),执行了多少条语句(Statement)。因此,覆盖率主要分为以下几种:
- 行覆盖率 Lines
- 分支覆盖率 Branch
- 函数覆盖率 Funcs
- 语句覆盖率 Stmts
实战案例
例如:对一个Stack类进行单元测试。
被测代码
// stack.js
export default class Stack {
constructor () {
this.stack = [];
}
push (...element) {
this.stack.push(...element);
}
pop () {
if (this.isEmpty()) {
return undefined;
}
return this.stack.pop();
}
peek () {
if (this.isEmpty()) {
return undefined;
}
return this.stack[this.stack.length - 1]
}
isEmpty () {
return this.stack.length === 0;
}
clear () {
this.stack = [];
}
size () {
return this.stack.length;
}
}
bad case
于是,我们很容易写出以下这样的单元测试:
// stack.test.js
test('test push', () => {
let stack = new Stack();
stack.push(1);
stack.push(2);
expect(stack.size()).toBe(2);
});
test('test pop', () => {
let stack = new Stack();
expect(stack.pop()).toBeUndefined();
stack.push(1);
stack.push(2);
expect(stack.pop()).toBe(2);
expect(stack.pop()).toBe(1);
expect(stack.pop()).toBeUndefined();
});
test('test peek', () => {
let stack = new Stack();
expect(stack.peek()).toBeUndefined();
stack.push(1);
stack.push(2);
expect(stack.peek()).toBe(2);
});
test('test isEmpty', () => {
let stack = new Stack();
expect(stack.isEmpty()).toBe(true);
stack.push(1);
expect(stack.isEmpty()).toBe(false);
});
test('test clear', () => {
let stack = new Stack();
stack.push(1);
expect(stack.size()).toBe(1);
stack.clear();
expect(stack.size()).toBe(0);
});
test('test size', () => {
let stack = new Stack();
stack.push(1);
expect(stack.size()).toBe(1);
});
但是呢,以上代码看着好像对每个方法都写了测试用例,测试覆盖率达到了100%,但其实上面的测试用例存在大量问题:
- 用例结构要分类,比如同一个方法的测试用例,我们可以用describle包围起来,这样整体结构更清晰
- 要合理进行复用,以上代码每个测试用例中都创建了stack实例,其实没必要,可以复用stack实例,并且要巧用生命周期方法。
- 按需要定制通用部分,即如果多个测试用例中有一些可以复用的逻辑,我们都可以抽象出来。
- 面向意图,而不是面向对象。
good case
所有的测试用例都是面向意图,而不是面向对象。而我们上面的测试代码正是犯了这个错误。我们只是简单为Stack中的每个方法添加了一个测试用例,但其实很多方法都是联动的,所以没必要从方法的数量维度去设计测试用例,而是要从意图,或者从实际使用的角度去设计测试用例。
并且,每个每个方法随着参数的不同,返回值的不同等,都会有很多种情况,那么这个时候,我们要为每一种可能发生的情况都要设计对应的测试用例。
// stack.test.js
describe('init stack', () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
test('default length', () => {
expect(stack.items.length).toBe(0);
});
});
describe('push method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
test('when push single,size increase 1', () => {
stack.push(1);
expect(stack.items.length).toBe(1);
});
test('when push multiple, size increase multiple', () => {
stack.push(1, 2, 3, 4);
expect(stack.items.length).toBe(4);
});
test('when push, get correct last item', () => {
stack.push(1);
expect(stack.items).toEqual([1]);
});
});
describe('pop method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
stack.push(1, 2, 3);
});
test('when pop, size decrease 1', () => {
expect(stack.items.length).toBe(3);
stack.pop();
expect(stack.items.length).toBe(2);
});
test('when pop, return last item', () => {
expect(stack.pop()).toBe(3);
});
test('when pop and stack is empty, return undefined', () => {
stack.pop();
stack.pop();
stack.pop();
expect(stack.pop()).toBeUndefined();
});
});
describe('peek method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
stack.push(1, 2, 3);
});
test('when stack is not empty, return last item', () => {
expect(stack.peek()).toBe(3);
});
test('when stack is empty, return undefined', () => {
stack.clear();
expect(stack.peek()).toBeUndefined();
});
});
describe('clear method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
stack.push(1, 2, 3);
});
test('when clear, size is 0', () => {
stack.clear();
expect(stack.items.length).toBe(0);
});
});
describe('size method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
stack.push(1, 2, 3);
});
test('return correct size', () => {
expect(stack.size()).toBe(3);
});
});
describe('isEmpty method', () => {
let stack;
beforeEach(() => {
stack = new Stack();
});
test('when stack is empty, return true', () => {
expect(stack.isEmpty()).toBe(true);
});
test('when stack is empty, return false', () => {
stack.push(1, 2, 3);
expect(stack.isEmpty()).toBe(false);
});
});
说明:
- 我们并不是只测试Stack的方法,同时要结合其实际使用,例如Stack的初始化操作等,我们也进行了单元测试。
- Push, pop等方法,我们都针对不同情况下的参数,以及返回值做了相应的测试用例。
case总结
通过这个简单case,我们可以清晰的感受如何去更好的写测试代码,通常要注意以下点:
- 要善用describe,把统一类型的测试用例组织起来,这样整体结构会更加清晰。
- 要善用生命周期钩子函数,我们通常可以在这些钩子函数中做一些可复用的操作,例如:数据的初始化,销毁等。
- 测试用例之间如果有公共的逻辑,我们也可以抽象成一个单独的方法。
- 测试用例的编写要从其实际使用情况出发,而不是单纯的面向对象去写测试用例。
总结
写单元测试,和我们平时写业务代码,所用的知识点其实没有本质上的区别,包括Jest内部提供的这些API,其实通过我们前面的介绍,看看官网,我们基本都可以掌握,个人感觉入门最麻烦的一点是思维方式的转变,尤其是Mock这块,我们需要知道什么时候进行Mock,小伙伴儿看完这节内容以后,再实际自己写代码体会体会,相信可以很快对单元测试有一个基本的认识和应用啦。
写作不易,如果觉得还不错的话,欢迎点赞和关注哦 😊😊😊