引入单元测试工具Jest
单元测试用于检查程序中的最小单元是否会按照预期设置工作。在过程式编程中,最小单元就是一个函数;而在面向对象编程中,最小单元是对象的方法。
Jest
是目前较流行的 JavaScript 单元测试框架,它提供了测试驱动、断言库、模拟底层函数、代码覆盖率等,而且使用起来相当简单。

Jest的安装与配置
要在 TypeScript 中使用 Jest
,首先,执行以下命令,安装 Jest
及用于 TypeScript 的 Jest
扩展。
$ npm install jest ts-jest @types/jest -D
上述命令将分别以发布到开发环境(-D
参数,其作用等同于 -save-dev
参数)的形式安装以下 3 个包。
-
jest:
Jest
的核心包。 -
ts-jest:
Jest
针对 TypeScript 的预处理器。 -
@types/jest:
Jest
的 TypeScript 声明文件(关于此类声明文件,详见21.2节)。
然后,使用以下命令创建 Jest
配置文件。
$ npx ts-jest config:init
之后会在命令运行目录中生成 jest.config.js
文件。然后,添加如下代码。
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: { "^.+\\.ts?$": "ts-jest" },
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"]
};
以上代码中加粗部分的含义如下。
-
transform
:自定义转换设置。此处配置为由ts-jest
预处理器来处理.ts
文件。 -
moduleFileExtensions
:指定要测试的文件类型的扩展名。
最后在 package.json
的 scripts
中加入
{
test: "jest"
// 如果要测试覆盖率,后面加上--coverage
// 如果要监听所有测试文件 --watchAll
}
下面就编写单元测试代码并执行。
编写和执行单元测试
首先,创建程序文件 a.ts
,代码如下。
export function sum(a: number, b: number) {
return a + b;
}
其中,创建并导出了一个名为 sum()
的函数,用于计算两数之和。
默认情况下,Jest
会把项目中所有扩展名为 .test.js
的文件当作单元测试文件。创建的单元测试文件必须符合该规则。
接着,创建测试文件 a.test.ts
,代码如下。
import { sum } from './a';
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
其中导入了 a.ts
文件中的 sum()
函数,然后使用 test()
函数测试,断言 sum(1,2)
的返回值为 3
。
将 a.ts
文件和 a.test.ts
文件都编译成 JavaScript 文件(a.js
文件和 a.test.js
文件)之后,执行以下命令以运行测试。
$ npx jest
Jest
测试结果如图20-3所示。
假设现在开发人员在维护代码时出现编写错误,误将 a.ts
文件修改为如下,那么将无法正确返回两数之和。
export function sum(a: number, b: number) {
return a + a;
}
编译 a.ts
文件,输出更新后的 a.js
文件,执行 npx jest
命令以运行测试,单元测试结果如图20-4所示。

可以看到单元测试成功检测到程序错误,sum(1,2)
的预期返回结果为 3,但实际为 2,不是两数之和。
匹配器
匹配器(Matchers
)是 Jest
中非常重要的一个概念,它可以提供很多种方式来让你去验证你所测试的返回值。举个例子就明白什么是匹配器了。
相等匹配,这是我们最常用的匹配规则
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
在这段代码中 expact(2 + 2)
将返回我们期望的结果,通常情况下我们只需要调用 expect
就可以,括号中的 可以是一个具有返回值的函数,也可以是表达式。后面的 toBe
就是一匹配器。
下面列举一些常用的匹配器:
普通匹配器
-
toBe
:object.is
相当于===
test('测试加法 3 + 7', () => { // toBe 匹配器 matchers object.is 相当于 === expect(10).toBe(10) })
-
toEqual
:内容相等,匹配内容,不匹配引用test('toEqual 匹配器', () => { // toEqual 匹配器 只会匹配内容,不会匹配引用 const a = { one: 1 } expect(a).toEqual({ one: 1 }) })
与真假有关的匹配器
-
toBeNull:只匹配 Null
test('toBeNull 匹配器', () => { // toBeNull const n = null; expect(n).toBeNull(); expect(n).toBeDefined(); expect(n).not.toBeUndefined(); expect(n).not.toBeTruthy(); expect(n).toBeFalsy(); });
-
toBeUndefined:只匹配 undefined
test('toBeUndefined 匹配器', () => { const a = undefined expect(a).toBeUndefined() })
-
toBeDefined: 与 toBeUndefined 相反,这里匹配 null 是通过的
test('toBeDefined 匹配器', () => { const a = null expect(a).toBeDefined() })
-
toBeTruthy:匹配任何 if 语句为 true
test('toBeTruthy 匹配器', () => { const a = 1 expect(a).toBeTruthy() })
-
toBeFalsy:匹配任何 if 语句为 false
test('toBeFalsy 匹配器', () => { const a = 0 expect(a).toBeFalsy() })
-
not:取反
test('not 匹配器', () => { const a = 1 // 以下两个匹配器是一样的 expect(a).not.toBeFalsy() expect(a).toBeTruthy() })
数字
-
toBeGreaterThan:大于
-
toBeLessThan:小于
-
toBeGreaterThanOrEqual:大于等于
-
toBeLessThanOrEqual:小于等于
-
toBeCloseTo:计算浮点数
test('two plus two', () => {
const value = 2 + 2;
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// toBe and toEqual are equivalent for numbers
expect(value).toBe(4);
expect(value).toEqual(4);
});
字符串
toMatch: 匹配某个特定项字符串,支持正则
test('toMatch', () => {
const str = 'http://www.zsh.com'
expect(str).toMatch('zsh')
expect(str).toMatch(/zsh/)
})
数组
toContain:匹配是否包含某个特定项
test('toContain', () => {
const arr = ['z', 's', 'h']
const data = new Set(arr)
expect(data).toContain('z')
})
异常
toThrow
const throwNewErrorFunc = () => {
throw new Error('this is a new error')
}
test('toThrow', () => {
// 抛出的异常也要一样才可以通过,也可以写正则表达式
expect(throwNewErrorFunc).toThrow('this is a new error')
})
function compileAndroidCode() {
throw new Error('you are using the wrong JDK!');
}
test('compiling android goes as expected', () => {
expect(() => compileAndroidCode()).toThrow();
expect(() => compileAndroidCode()).toThrow(Error);
// You can also use a string that must be contained in the error message or a regexp
expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK');
expect(() => compileAndroidCode()).toThrow(/JDK/);
// Or you can match an exact error message using a regexp like below
expect(() => compileAndroidCode()).toThrow(/^you are using the wrong JDK$/); // Test fails
expect(() => compileAndroidCode()).toThrow(/^you are using the wrong JDK!$/); // Test pass
});
测试异步代码
假设请求函数如下:
const fethUserInfo = fetch('http://xxxx')
测试异步代码有好几种方式,我就推荐一种我认为比较常用的方式:
// fetchData.test.js
// 测试promise成功需要加.resolves方法
test('the data is peanut butter', async () => {
await expect(fethUserInfo()).resolves.toBe('peanut butter');
});
// 测试promise成功需要加.rejects方法
test('the fetch fails with an error', async () => {
await expect(fethUserInfo()).rejects.toMatch('error');
});
作用域
jest
提供一个 describle
函数来分离各个 test
测试用例,就是把相关的代码放到一类分组中,这么简单,看个例子就懂了。
// 分组一
describe('Test xxFunction', () => {
test('Test default return zero', () => {
expect(xxFunction()).toBe(0)
})
// ...其它test
})
// 分组二
describe('Test xxFunction2', () => {
test('Pass 3 can return 9', () => {
expect(xxFunction2(3)).toBe(9)
})
// ...其它test
})
钩子函数
jest
中有 4 个钩子函数:
-
beforeAll:所有测试之前执行
-
afterAll:所有测试执行完之后
-
beforeEach:每个测试实例之前执行
-
afterEach:每个测试实例完成之后执行
我们举例来说明为什么需要他们。在 index.js
中写入一些待测试方法
export default class compute {
constructor() {
this.number = 0
}
addOne() {
this.number += 1
}
addTwo() {
this.number += 2
}
minusOne() {
this.number -= 1
}
minusTwo() {
this.number -= 2
}
}
假如我们要在 index.test.js
中写测试实例:
import compute from './index'
const Compute = new compute()
test('测试 addOne', () => {
Compute.addOne()
expect(Compute.number).toBe(1)
})
test('测试 minusOne', () => {
Compute.minusOne()
expect(Compute.number).toBe(0)
})
-
这里两个测试实例相互之间影响了,共用了一个
compute
实例,我们可以将const Compute = new compute()
放在beforeEach
里面就可以解决了,每次测试实例之前先重新new compute
。 -
同理,你想在每个
test
测试完毕后单独运行什么可以放入到afterEach
中。
我们接着看一下什么情况下使用 beforeAll
,假如我们测试数据库数据是否保存正确:
-
我们在测试最开始,也就是
beforeAll
生命周期里,新增 1 条数据到数据库里 -
测试完后,也就是
afterAll
周期里,删除之前添加的数据 -
最后利用全局作用域
afterAll
确认数据库是否还原成初始状态
// 模拟数据库
const userDB = [
{ id: 1, name: '小明' },
{ id: 2, name: '小花' },
]
// 新增数据
const insertTestData = data => {
// userDB,push数据
}
// 删除数据
const deleteTestData = id => {
// userDB,delete数据
}
// 全部测试完
afterAll(() => {
console.log(userDB)
})
describe('Test about user data', () => {
beforeAll(() => {
insertTestData({ id: 99, name: 'CS' })
})
afterAll(() => {
deleteTestData(99)
})
})
jest 里的 Mock
为什么要使用 Mock 函数?
在项目中,经常会碰见 A
模块调 B
模块的方法。并且,在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。此时,就需要 mock
函数了。
Mock
函数提供的以下三种特性,在我们写测试代码时十分有用:
-
捕获函数调用情况
-
设置函数返回值
-
改变函数的内部实现
jest.fn()
jest.fn()
是创建 Mock
函数最常用的方式。
test('测试jest.fn()', () => {
let mockFn = jest.fn();
let result = mockFn(1);
// 断言mockFn被调用
expect(mockFn).toBeCalled();
// 断言mockFn被调用了一次
expect(mockFn).toBeCalledTimes(1);
// 断言mockFn传入的参数为1
expect(mockFn).toHaveBeenCalledWith(1);
})
jest.fn()
所创建的 Mock
函数还可以 设置返回值,定义内部实现 或 返回 Promise
对象。
test('测试jest.fn()返回固定值', () => {
let mockFn = jest.fn().mockReturnValue('default');
// 断言mockFn执行后返回值为default
expect(mockFn()).toBe('default');
})
test('测试jest.fn()内部实现', () => {
let mockFn = jest.fn((num1, num2) => {
return num1 * num2;
})
// 断言mockFn执行后返回100
expect(mockFn(10, 10)).toBe(100);
})
test('测试jest.fn()返回Promise', async () => {
let mockFn = jest.fn().mockResolvedValue('default');
let result = await mockFn();
// 断言mockFn通过await关键字执行后返回值为default
expect(result).toBe('default');
// 断言mockFn调用后返回的是Promise对象
expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
jest.mock()
fetch.js
文件夹中封装的请求方法可能我们在其他模块被调用的时候,并不需要进行实际的请求(请求方法已经通过单测或需要该方法返回非真实数据)。此时,使用 jest.mock()
去 mock
整个模块是十分有必要的。
下面我们在 src/fetch.js
的同级目录下创建一个 src/events.js
。
import fetch from './fetch';
export default {
async getPostList() {
return fetch.fetchPostsList(data => {
console.log('fetchPostsList be called!');
// do something
});
}
}
import events from '../src/events';
import fetch from '../src/fetch';
jest.mock('../src/fetch.js');
test('mock 整个 fetch.js模块', async () => {
expect.assertions(2);
await events.getPostList();
expect(fetch.fetchPostsList).toHaveBeenCalled();
expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});
在测试代码中我们使用了 jest.mock('../src/fetch.js')
去 mock
整个 fetch.js
模块。
jest.spyOn()
jest.spyOn()
方法同样创建一个 mock
函数,但是该 mock
函数不仅能够捕获函数的调用情况,还可以正常的执行被 spy
的函数。实际上,jest.spyOn()
是 jest.fn()
的语法糖,它创建了一个和被 spy
的函数具有相同内部代码的 mock
函数。
之前 jest.mock()
的示例代码中的正确执行结果的截图,从 shell 脚本中可以看到 console.log('fetchPostsList be called!');
这行代码并没有在 shell 中被打印,这是因为通过 jest.mock()
后,模块内的方法是不会被 jest
所实际执行的。这时我们就需要使用 jest.spyOn()
。
// functions.test.js
import events from '../src/events';
import fetch from '../src/fetch';
test('使用jest.spyOn()监控fetch.fetchPostsList被正常调用', async() => {
expect.assertions(2);
const spyFn = jest.spyOn(fetch, 'fetchPostsList');
await events.getPostList();
expect(spyFn).toHaveBeenCalled();
expect(spyFn).toHaveBeenCalledTimes(1);
})
执行 npm run test
后,可以看到 shell 中的打印信息,说明通过 jest.spyOn()
,fetchPostsList
被正常的执行了。
快照
快照就是对你对比的数据会存一份副本,啥意思呢,我们举个例子:
export const data2 = () => {
return {
name: 'zhangsan',
age: 26,
time: new Date()
}
}
在 index.test.js
中写入一些测试实例
import { data2 } from "./index"
it('测试快照 data2', () => {
expect(data2()).toMatchSnapshot({
name: 'zhangsan',
age: 26,
time: expect.any(Date) //用于声明是个时间类型,否则时间会一直改变,快照不通过
})
})
-
toMatchSnapshot
会将参数将快照进行匹配 -
expect.any(Date)
用于匹配一个时间类型
执行 npm run test
会生成一个 __snapshots__
文件夹,里面是生成的快照,当你修改一下测试代码时,会提示你,快照不匹配。如果你确定你需要修改,按 u
键,即可更新快照。这用于 UI 组件的测试非常有用。
React的BDD单测
接下来我们看下 react 代码如何进行测试,用一个很小的例子来说明。
案例中引入了 enzyme。Enzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。
我们达成的目的是检测:
-
用户进入首页,看到两个按钮,分别是 counter1 和 counter2
-
点击 counter1,就能看到两个按钮的文字部分分别是 "counter1" 和 "counter2"
react 代码如下:
import React from 'react';
function Counter(){
return (
<ul>
<li>
<button id='counter1' className='button1'>counter1</button>
</li>
<li>
<button id='counter2' className='button2'>counter2</button>
</li>
</ul>
)
}
单测的文件:
import Counter from 'xx';
import { mount } from 'enzyme';
describle('测试APP',() => {
test('用户进入首页,看到两个按钮,分别是counter1和counter2,并且按钮文字也是counter1和counter2',()=>{
const wrapper = mount(<Counter />);
const button = wrapper.find('button');
except(button).toHaveLength(2);
except(button.at(0).text()).toBe('counter1');
except(button.at(1).text()).toBe('counter2');
})
})