上一篇中,我们用 TypeScript 实现了发布订阅模式。但是,我们这个《每日一写》的项目,总不能就写几行代码让它在哪里裸奔,连个测试都不支持吧。于是,这篇就来配置Jest进行测试。
一、Jest 配置步骤
1、安装 jest 和 @types/jest
pnpm add jest @types/jest -D
2、初始化jest.config.js
# 生成jest.config.js文件
npx jest --init
3、安装 babel-jest、@babel/core、@babel/preset-env、@babel/preset-typescript
因为我们要用 TypeScript 写代码文件和测试文件,而 Jest 是不能直接执行 Typescript 的,所以需要通过 babel 来处理。
pnpm add -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript
4、配置 babel.config.js
内容如下:
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current'
}
}
],
'@babel/preset-typescript',
],
};
5、在 package.json 的 scripts 下添加 "test": "jest"
"scripts": {
"start": "tsc --watch --project tsconfig.json",
"test": "jest"
},
这样,我们就可以通过 pnpm test
来执行测试了。
因为项目中暂时还没有用到UI组件,所以,关于UI组件的测试暂且留个坑。后面用到的时候再来填坑。
二、使用 Jest 进行测试
开始动手写测试代码之前,我们先要弄清楚 Jest 中的两个概念 —— describe 和 test:
-
describe(name, fn)
describe块称为测试套件(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称,第二个参数是一个实际执行的函数。 -
test(name, fn, timeout)
test块称为"测试用例"(test case),表示一个单独的测试,是测试的最小单位。
test('adds 1 + 2 to equal 3', () => {\
expect(sum(1, 2)).toBe(3);\
});
1、为发布订阅模式的代码编写测试代码
关于发布订阅模式的实现代码见上篇文章。
因为很简单,我就上代码了(src/1、实现发布订阅模式/index.test.ts文件):
import { Event } from './';
describe('Event', () => {
const eventBus = new Event();
const b = (params: unknown) => {
console.log('B recieved', params);
};
const eventType = 'publish';
let mockB: typeof b;
// 执行每个test之前都会做一遍的事情
beforeEach(() => {
mockB = jest.fn(b);
});
test('add and emit a event, listener is called', () => {
eventBus.on(eventType, mockB);
eventBus.emit(eventType, 'message 1');
expect(mockB).toBeCalledTimes(1);
// 注意这里是个数组
expect(mockB).toBeCalledWith(['message 1']);
eventBus.off(eventType, mockB);
});
test('given an not exist event, throw error', () => {
eventBus.on(eventType, mockB);
const event = eventType + '123';
// 注意这里要把会跑出错误的代码放在函数中,否则,如果写成expect(eventBus.off(event, mockB)).toThrow('未绑定该事件')的话,执行测试的时候是会报错的,导致永远也无法测试通过。
expect(() => eventBus.off(event, mockB)).toThrow('未绑定该事件');
});
test('passing no handler to off, all handlers of this type removed', () => {
// @ts-ignore-next-line
eventBus.off(eventType);
expect(eventBus.handlers[eventType]).toBeUndefined();
});
test('passing error handler to off, throw error', () => {
eventBus.on(eventType, mockB);
expect(() => eventBus.off(eventType, () => {})).toThrow();
eventBus.off(eventType, mockB);
});
test('remove event, emit will not trigger callback', () => {
eventBus.on(eventType, mockB);
eventBus.off(eventType, mockB);
eventBus.emit(eventType, 'message 2');
expect(mockB).not.toBeCalled();
});
});
这里特别提一下这句代码:
expect(() => eventBus.off(event, mockB)).toThrow('未绑定该事件');
注意这里要把会跑出错误的代码放在函数中,否则,如果写成expect(eventBus.off(event, mockB)).toThrow('未绑定该事件')的话,执行测试的时候是会报错的,导致永远也无法测试通过。
完成上面的测试代码后,执行 pnpm test
,就可以看到如下测试结果了。
在编写测试用例的过程中,对于没有测试到的代码行号,会列在上图的 Uncovered Line 这一列中。你可以根据这个提示来逐步去完善测试用例,让测试覆盖率达到比较理想的水平。
2、为观察者模式的代码编写测试代码
关于观察者模式的实现代码见上篇文章。
同样直接上代码(src/2、实现观察者模式/index.test.ts):
import { Subject, Observer } from './';
describe('Subject', () => {
let subject: Subject;
beforeEach(() => {
subject = new Subject();
});
test('add observer', () => {
const observer = new Observer('b');
subject.add(observer);
expect(subject.observers).toContain(observer);
subject.remove(observer);
});
test('remove observer', () => {
const observer = new Observer('b');
subject.add(observer);
subject.remove(observer);
expect(subject.observers).not.toContain(observer);
});
test('remove not exist observer, throw error', () => {
const observer = new Observer('b');
expect(() => subject.remove(observer)).toThrow('没有该observer');
});
test('notify', () => {
const observerB = new Observer('b');
const observerC = new Observer('c');
// 注意这里jest.spyOn的使用,这里不能用jest.fn
const mockUpdateB = jest.spyOn(observerB, 'update');
const mockUpdateC = jest.spyOn(observerC, 'update');
subject.add(observerB);
subject.add(observerC);
subject.notify();
expect(mockUpdateB).toBeCalledTimes(1);
expect(mockUpdateC).toBeCalledTimes(1);
subject.remove(observerB);
subject.remove(observerC);
});
});
值得注意的是,下面这里 jest.spyOn
的使用,这里不能用 jest.fn
:
const mockUpdateB = jest.spyOn(observerB, 'update');
相关的代码见这里。
三、Jest 的基本使用
这部分主要是做个整理,方便查阅,你也可以直接阅读 Jest官方文档。
1、钩子函数
-
beforeEach(fn, timeout) // 每个测试用例执行前执行
-
afterEach(fn, timeout) // 每个测试用例执行后执行
-
beforeAll(fn, timeout) // 所有测试用例测试之前执行
-
afterAll(fn, timeout) // 所有测试用例测试之后执行
2、常用的匹配器(Matcher)
(1)是与内容相等
-
.toBe(value)
-
.toEqual(value)
注意这两者的区别:toBe
使用 Object.is
来进行精准匹配的测试;如果您想要检查对象的值,请使用 toEqual
代替。例如:
test('对象赋值', () => {
const data = {one: 1};
data['two'] = 2;
expect(data).toEqual({one: 1, two: 2});
});
(2)取反
- .not // 取反
(3)真值、空值
-
.toBeTruthy() // 匹配真
-
.toBeFalsy() // 匹配假
-
.toBeNull() // 匹配null
-
.toBeUndefined() // 匹配undefined
-
.toBeDefined() // 与.toBeUndefined()相反
(4)数字
-
.toBeGreaterThan(number | bigint) // 匹配大于
-
.toBeGreaterThanOrEqual(number | bigint) // 匹配大于等于
-
.toBeLessThan(number | bigint) // 匹配小于
-
.toBeLessThanOrEqual(number | bigint) // 匹配小于等于
-
.toHaveLength(number) // 匹配长度
(5)字符串匹配
- .toMatch(/xxx/) // 例如,expect('Christoph').toMatch(/stop/);
(6)数组和可迭代对象
- .toContain(xxx) // 检查一个数组或可迭代对象是否包含某个特定项
const shoppingList = ['diapers', 'milk'];
expect(shoppingList).toContain('milk');
expect(new Set(shoppingList)).toContain('milk');
(7)抛出异常
- .toThrow(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);
// 你可以自己定义确切的错误消息内容或者使用正则表达式
expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK');
expect(() => compileAndroidCode()).toThrow(/JDK/);
});
(8)函数调用
-
.toBeCalled() // 匹配函数被调用
-
.toHaveBeenCalledTimes(number) // 匹配函数调用次数
-
.toHaveBeenCalledWith(arg1, arg2, ...) // 匹配函数调用的参数
-
.toHaveBeenLastCalledWith(arg1, arg2, ...) // 匹配最后一次调用的返回值
(9)快照测试
每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。
典型的做法是在渲染了UI组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者UI组件已经更新到了新版本。
import React from 'react';
import renderer from 'react-test-renderer';
import Link from '../Link';
it('renders correctly', () => {
const tree = renderer
.create(<Link page="http://www.facebook.com">Facebook</Link>)
.toJSON();
expect(tree).toMatchSnapshot();
});
测试第一次运行的时候,jest会生成一个快照文件,内容形如:
exports[`renders correctly 1`] = `
<a
className="normal"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
`;
你可以运行 Jest 加一个标识符来重新生成快照文件。
jest --updateSnapshot
-
.toMatchSnapshot(propertyMatchers?, hint?) // 匹配生成的快照文件
-
.toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot) // 匹配内联快照
内联快照和普通快照(.snap
文件)表现一致,只是会将快照值自动写会源代码中。这意味着你可以从自动生成的快照中受益,并且不用切换到额外生成的快照文件中保证值的正确性。
首先,你写一个测试,并调用 .toMatchInlineSnapshot()
,不需要传递参数。
it('renders correctly', () => {
const tree = renderer
.create(<Link page="https://example.com">Example Site</Link>)
.toJSON();
expect(tree).toMatchInlineSnapshot();
});
下次运行Jest时,tree
会被重新评估,此次的快照会被当作一个参数传递到 toMatchInlineSnapshot
:
3、expect函数
关于它们的具体使用,详见官方文档。这里有非常详细的说明,而且都有示例。
-
expect(value) // 后边可以跟匹配器
-
expect.anything() // 匹配除了null和undefined的所有,可以检查是否使用非空参数调用模拟函数
-
expect.any(constructor) // 匹配任何构造器
-
expect.objectContaining(object) // 匹配一个对象包含
-
expect.not.objectContaining(object)
-
expect.arrayContaining(array) // 匹配一个数组包含
-
expect.not.arrayContaining(array)
-
expect.stringContaining(string) // 匹配字符串包含
-
expect.not.stringContaining(string)
-
expect.extend(matchers) // 自定义匹配器
-
expect.assertions(number) // 验证断言次数
-
expect.hasAssertions() // 验证至少有一个断言
例子:
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () =>
`expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
};
} else {
return {
message: () =>
`expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
};
}
},
});
test('numeric ranges', () => {
expect(100).toBeWithinRange(90, 110);
expect(101).not.toBeWithinRange(0, 100);
expect({apples: 6, bananas: 3}).toEqual({
apples: expect.toBeWithinRange(1, 10),
bananas: expect.not.toBeWithinRange(11, 20),
});
});