Jest 基础
创建项目
建项目
bash1mkdir jest-ts 2cd jest-ts 3npm init -y
安装 jest、ts-node 和 typescript 以及一些类型声明文件
bash1npm i -D jest @types/jest @jest/types ts-node typescript 2npx jest --init
--init是用jest-cli初始化 jest 配置文件,根据提示和自己的需求配置即可。
这里贴一下我的配置

解释一下:
- 第三个选项——前端测试会涉及 dom 操作,所以用 browser-like,如果是 node 环境,则可以选 node。
- 第四个选项——覆盖率报告,需要用到
- 第五个选项——用 babel 可以避免某些兼容性问题
- 测试完成后自动清理 mock,实例等缓存以避免用例之间互相影响
即使选项不一致也没问题,因为后面可以修改配置文件来修改上面的选择配置
执行完之后,会生成jest.config.js的配置文件。
TS Support
想要支持 TypeScript,就必须转译 ts 代码。
Jest 本身不支持代码转译。在执行时会调用转译器/编译器做代码转译。
这样就能够对 TypeScript 转译成 JS 代码从而让 Jest 支持对 TypeScript 的测试。
转译器有两种,我主要使用 babel以减少某些兼容性导致的影响。
使用 ts-jest
bash1npm i -D ts-jest
- 安装 ts-jest 。ts-jest 用来转译 TypeScript 的代码
- 按照 jest 类型声明
- ts-node 用来启动 TypeScript 代码
ts-jest 的大版本要和 jest 的一致,否则容易产生兼容问题。
生成 TypeScript 配置文件
bash1tsc --init
在jest.config.js中添加配置:
js1module.exports = { 2 transform: { 3 '^.+\\.ts?$': 'ts-jest', 4 }, 5 ... 6 }
babal 转译
bash1npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript
这里主要安装 babel 的预设插件和 react 以及 typescript 的预设插件。
安装完成后,我们在根目录创建一个 babel.config.js 文件用于 babel 的配置
js1module.exports = { 2 presets: [ 3 ['@babel/preset-env', { targets: { node: 'current' } }], 4 ['@babel/preset-react', { runtime: 'automatic' }], // 自动导入react 5 '@babel/preset-typescript', 6 ], 7}
引入 dom 环境
为了还原浏览器的全部功能,需要引入 jest 提供的 jsdom 环境。
jest 28 版本需要额外安装依赖
bash1npm i -D jest-environment-jsdom
jest-environment-jsdom可以看成是独立的包,它内部实现了浏览器的大部分功能,可以看作是 Jest 版本的仿浏览器宿主环境。
当这个包安装好之后,需要让 Jest 识别到这个环境,有两种不同的方法:
-
在
jest.config.js中配置js1module.exports = { 2 testEnvironment: 'jest-environment-jsdom', 3 ... 4 } -
将这个包作为全局 mock
在 src 目录下创建 test 目录,并创建 setup.ts 文件,内容如下:
js1// setup.ts 2import 'jest-environment-jsdom'在
jest.config.js中配置js1module.exports = { 2 setupFilesAfterEnv: ['./src/test/setup.ts'], 3 ... 4 }这个选项是用来执行装载文件的——当 Jest 测试环境启动后,会将指定路径下的文件运行一遍,一般用来装载全局 mock 环境。
很多第三方的 mock 插件安装后都会经过 setup 文件给 Jest 环境装载某些功能。
跟
setupFilesAfterEnv类似还有一个setupFiles选项,它也可以用来设置全局 Mock 数据。
setupFiles比setupFilesAfterEnv更早执行,所以也可以用来设置 Mock 全局变量。不同之处在于,由于
setupFilesAfterEnv是在引入测试框架 Jest 之后运行的,所以能够在此引入和配置Jset/Jasmine的插件。如果在
setupFiles中添加 Jest 的扩展和插件,就会得到报错。这里有个 issue 可以参考
额外扩展
-
额外的扩展名识别
因为 Jest 不使用 Webpack 等打包工具,因此它不知道如何加载除 js/jsx 之外的其他文件扩展名,所以我们需要为它加一个转换器。
js1// jest.config.ts 2export default { 3 // ... other config 4 transform: { 5 // ... 6 '^.+.(js|ts|tsx)$': '<rootDir>/node_modules/babel-jest', 7 }, 8} -
SVG mock
我们项目中可能会有用到 svg 等图片,这个对于 Jest 是无法识别的,我们需要对它进行 mock,返回相同的输出结果。
js1// jest.config.ts 2export default { 3 // ... other config 4 transform: { 5 // ... 6 '^.+.svg$': '<rootDir>/mock/svg-transform.js', 7 }, 8}js1// mock/svg-transform.js 2module.exports = { 3 process() { 4 return { code: 'module.exports = {};' } 5 }, 6 getCacheKey() { 7 return 'svgTransform' // SVG固定返回这个字符串 8 }, 9} -
CSS 代理
Jest 本身不知道如何处理不同扩展的文件,我们可以通过配置代理的方式,告诉 Jest 将此对象模拟为导入的 CSS 模块。
bash1npm install --save-dev identity-obj-proxyjs1// jest.config.ts 2export default { 3 // ... other config 4 moduleNameMapper: { 5 '.(css|less|sass|scss)$': 'identity-obj-proxy', 6 }, 7}
测试 TypeScript 代码
测试 sum 函数
js1// src/utils/sum.ts 2export function sum(a: number, b: number) { 3 return a + b 4}
js1// src/test/sum.test.ts 2import { sum } from '../utils/sum' 3 4describe('测试 sum 函数', () => { 5 it('相加', () => { 6 expect(sum(1, 2)).toBe(3) 7 }) 8})
测试是否支持 DOM环境
js1// src/utils/dom.ts 2export function addDiv() { 3 const div = document.createElement('div') 4 document.body.append(div) 5}
js1// src/test/dom.test.ts 2import { addDiv } from '../util/dom' 3 4describe('测试是否支持浏览器环境', () => { 5 it('addDiv', () => { 6 addDiv() 7 expect(document.body.querySelectorAll('div')).toHaveLength(1) 8 }) 9})
项目结构
bash1├── package-lock.json 2├── package.json 3├── src 4│ ├── test 5│ │ ├── dom.test.ts 6│ │ ├── setup.ts 7│ │ └── sum.test.ts 8│ └── util 9│ ├── dom.ts 10│ └── sum.ts 11└── tsconfig.json
启动测试:
npm run test
得到覆盖率报告

自动运行 jest
如果希望能够在修改 test 文件后自动运行 jest,我们需要在package.json的脚本命令后加上--watchAll:
json1 "scripts": { 2 "test": "jest --watchAll" 3 },
--watchAll 会在变更后测试所有的文件。
如果只希望测试变更过的文件,则可以改成
json1 "scripts": { 2 "test": "jest --watch" 3 },
在该模式下,会自动运行 o 模式 ——参见 测试时命令模式
单文件测试
如果只想要测试一个 js 文件,则使用以下命令:
bash1npm run test <文件相对路径>
例如:
bash1 npm run test ./src/test/sum.test.ts
覆盖率报告
终端展示的是覆盖率情况,还有更加详细的覆盖率报告。
覆盖率报告在新生成的coverage目录中,有不同格式的覆盖率报告
bash1├── clover.xml 2├── coverage-final.json 3├── lcov-report 4│ ├── base.css 5│ ├── block-navigation.js 6│ ├── dom.ts.html 7│ ├── favicon.png 8│ ├── index.html 9│ ├── prettify.css 10│ ├── prettify.js 11│ ├── sort-arrow-sprite.png 12│ ├── sorter.js 13│ └── sum.ts.html 14└── lcov.info
我们可以打开 index.html 来查看网页版本的报告
js1cd coverage/lcov-report 2hs -c-1 . // 用 http-server 打开index.html

路径别名
所谓路径别名就是我希望从这个引入:
js1import { sum } from '../util/sum'
换成这个引入
js1import { sum } from 'util/sum'
实际上就是把utils映射成src/utils。
首先要在 moduleDirectories中配置
json1// jest.config.js 2 moduleDirectories: ['node_modules', 'src'],
这里的意思是让 jest 可以直接到 node_modules 或者 src 下去找文件。
其次在tsconfig中配置 path
js1 "baseUrl": "./", 2 "paths": { 3 "util/*": ["src/util/*"] 4 },
这样 ts 文件就能够按照 paths 的配置去对应的映射路径中找到该文件。
如果要改成这样引入:
js1import { addDiv } from '@/util/dom'
@代表src。
jest 想要识别这个符号就需要在moduleNameMapper中配置:
js1modulex.exports = { 2 moduleNameMapper: { '@/(.*)': '<rootDir>/src/$1' }, 3}
这是 jest 的路径匹配。
然后 tsconfig 中也加入对@的映射。
js1 "baseUrl": "./", 2 "paths": { 3 "utils/*": ["src/utils/*"], 4 "@/*": ["src/*"] 5 },
此时我们就可以用两种方式引入啦
js1import { sum } from '@/utils/sum' 2// import { sum } from 'utils/sum'; 3 4describe('sum', () => { 5 it('可以相加', () => { 6 expect(sum(1, 1)).toEqual(2) 7 }) 8})
测试代码如何 debug
在 vscode 上用 Typescript 打断点实在过于麻烦,需要各种配置,而且结合 jest 会遇到各种各样的问题。
这里介绍一种简单的,通过 chrome 浏览器的 dev-tool 打断点的方法:
在package.json中配置一个脚本:
js1 "debug": "node --inspect ./node_modules/jest/bin/jest --runInBand --no-cache --no-watchman"
打开 chrome 浏览器,在地址栏输入
chrome://inspect/
点击这里:

在代码中输入debugger,然后执行npm run debug,会出现以下chrome inspect界面

然后就可以愉快地打断点+调试代码啦。
命令模式
在--watch<All>的情况下,会有一种交互型的命令模式。

- f 只跑失败的用例
- o 只测试改变的文件(配合 git 使用,相当于 --watch)
- p 根据正则表达式测试对应的文件名里的用例
- t 根据正则表达式测试 test 函数里对应的 name 的用例
- q 退出 watch 模式
- i 以交互方式运行失败的测试用例
- enter 重新触发一次测试
- a 测试 all 文件(相当于 --watchAll)
这些命令模式主要功能是更好地支撑用户对测试的需求。
【强烈推荐】编辑器还可以下载 Jest 官方插件,用可视化的界面支撑 Jest 测试,例如 VSCode 平台下的Jest
断言匹配器
Jest 使用断言匹配器来帮助我们编写基本的测试预期,主要是对一些数据的预期判断。
匹配器(matcher)有很多种,常见的有:
-
toBe 匹配简单数据类型是否相等
js1expect(1 + 2).toBe(3) -
toEqual 深度匹配对象内容相等
js1expect({ age: 12 }).toEqual({ age: 12 }) -
toBeNull 是否为 null
js1let n = null 2expect(n).toBeNull() -
toBeUndefined 是否为 undefined
js1let u = undefined 2expect(u).toBeUndefined() -
toBeFalsy 是否为 假值
js1let s = 0 2expect(s).toBeFalsy() -
toBeDefined 是否为非 undefined
js1let u = null 2expect(u).toBeDefined() -
toBeTruthy 是否为 真值
js1let t = 1 2expect(t).toBeTruthy() -
not.to
匹配器取反 js1let t = 1 2expect(t).not.toBeUndefined() -
toBeGreaterThan 比某个大
-
toBeLessThan 比某个小
-
toBeGreaterThanOrEqual 大于或者等于
-
toBeLessThanOrEqual 小于或者等于
-
toBeCloseTo 是否接近于,用于浮点数的计算
js1expect(0.1 + 0.2).toBeCloseTo(0.3) -
toMatch 字符串是否匹配
-
js
1expect('hello world').toMatch(/hello/) -
toContain 数组或者 Set 结构是否包含一个项(简单数据类型)
js1expect(['123', '456']).toContain('123') -
toContainEqual 数组或者 Set 结构是否包含一个项(复杂数据类型)
js1expect([{ name: 'qxy' }]).toContainEqual({ name: 'qxy' }) -
toThrow 是否抛出一个异常,可以传入字符串检查抛出的内容
js1expect(throwError).toThrowError() 2expect(throwError).toThrowError('get an error') -
toMatchObject 匹配对象的内容
js1expect({ name: 'qyx', age: 12 }).toMatchObject({ age: 12 }) 2expect([{ name: 'qyx', age: 12 }]).toMatchObject([{ age: 12 }])
...等等
测试异步代码
测试 callback
js1// fetchData.ts 2import axios from 'axios' 3 4export async function fetchData(callback: (d: any) => void) { 5 const api = 'https://api.github.com/users/18888628835' 6 axios.get(api).then((res) => callback(res.data)) 7}
js1// fetchData.test.ts 2import { fetchData } from '../util/fetchData' 3 4describe('测试异步函数', () => { 5 it('fetchData callback', (done) => { 6 fetchData((res) => { 7 expect(res.login).toBe('18888628835') 8 done() 9 }) 10 }) 11})
callback 会在 then 后进入微任务队列,这时候fetchData函数已经调用完毕了。我们该如何告诉 jest,还需要等异步函数调用结束测试才算完成?
jest 提供了一个 done 函数,只有 done 函数执行完成,才表示测试的异步函数调用结束了。
测试 promise
通过 async await 来测试 promise
js1import axios from 'axios' 2 3describe('测试 promise', () => { 4 function fetchData() { 5 const api = 'https://api.github.com/users/18888628835' 6 return axios.get(api) 7 } 8 it('async await', async () => { 9 const res = await fetchData() 10 expect(res.data.login).toBe('18888628835') 11 }) 12})
通过 then 和 return
js1describe('测试 promise', () => { 2 function fetchData() { 3 const api = 'https://api.github.com/users/18888628835' 4 return axios.get(api) 5 } 6 it('通过 then 和 return', () => { 7 return fetchData().then((res) => expect(res.data.login).toBe('18888628835')) 8 }) 9})
通过 resolves
js1describe('测试 promise', () => { 2 function fetchData() { 3 const api = 'https://api.github.com/users/18888628835' 4 return axios.get(api) 5 } 6 it('通过 resolves', () => { 7 return expect(fetchData()).resolves.toMatchObject({ 8 data: { login: '18888628835' }, 9 }) 10 }) 11})
测试是否能 catch 到 error
通过catch 和 assertions
js1import axios from 'axios' 2 3describe('测试 catch error', () => { 4 function fetchData() { 5 const api = 'http://www.dell-lee.com/react/api/demo2.json' 6 return axios.get(api) 7 } 8 it('fetchData fails with error', () => { 9 expect.assertions(1) 10 return fetchData().catch((e) => { 11 console.log('🚀🚀🚀🚀🚀 - e:', e.toString()) //e: AxiosError: Request failed with status code 404 12 expect(e.toString().indexOf('404') > -1).toBe(true) 13 }) 14 }) 15})
通过expect.assertions断言 expect 至少能够执行一次。
否则上面的代码即使没有 catch 到 error,也能通过测试。因为 fetchData 顺利执行了。
通过 rejects和toThrow
js1describe('测试异步函数', () => { 2 function fetchData() { 3 const api = 'http://www.dell-lee.com/react/api/demo1.json' 4 return axios.get(api) 5 } 6 it('fetchData with rejects and toThrow', () => { 7 return expect(fetchData()).rejects.toThrow() 8 }) 9})
通过 try 和 catch
js1describe('测试异步函数', () => { 2 function fetchData() { 3 const api = 'http://www.dell-lee.com/react/api/demo1.json' 4 return axios.get(api) 5 } 6 it('fetchData', async () => { 7 expect.assertions(1) 8 try { 9 await fetchData() 10 } catch (error) { 11 if (error instanceof Error) { 12 expect(error.toString()).toMatch('Error') 13 } 14 } 15 }) 16})
钩子函数
钩子函数是自动运行的回调函数。Jest 允许每次在执行测试前后自动运行钩子函数里注册的函数。
beforeAll
例如,下面有一个 Counter 类
js1export class Counter { 2 number 3 constructor() { 4 this.number = 0 5 } 6 addOne() { 7 this.number += 1 8 } 9 minusOne() { 10 this.number -= 1 11 } 12}
它有一个addOne 和一个minusOne 的方法
当我对这两个函数进行测试
js1import { Counter } from '../util/counter' 2 3let counter: Counter 4 5describe('测试 counter', () => { 6 beforeAll(() => { 7 console.log('beforeAll', counter) 8 counter = new Counter() 9 }) 10 11 it('测试 addOne', () => { 12 counter.addOne() 13 expect(counter.number).toBe(1) 14 }) 15 16 it('测试 minusOne', () => { 17 counter.minusOne() 18 expect(counter.number).toBe(0) 19 }) 20})
上面的代码使用beforeAll这个钩子函数,执行 it 函数前,都会调用beforeAll函数里的代码初始化 counter 变量。
afterAll
与上面对应,当执行完所有 it 函数后,会调用 afterAll 钩子函数。
beforeEach
每一个it 函数执行前,都会执行 beforeEach 钩子函数。
js1describe('测试 counter', () => { 2 beforeEach(() => { 3 counter = new Counter() 4 }) 5 6 it('测试 addOne', () => { 7 counter.addOne() 8 expect(counter.number).toBe(1) 9 }) 10 11 it('测试 minusOne', () => { 12 counter.minusOne() 13 expect(counter.number).toBe(-1) 14 }) 15})
上面的代码在每次执行 it 函数前,都会调用一下 beforeEach 里的函数将 counter 初始化为一个新的 Counter 实例,这样做的好处是能够保证各个 it 函数内的数据不受互相的影响。
afterEach
与 beforeEach 对应,在每个 it 函数调用之后会执行 afterEach 注册的函数。
describe
describe 函数是分组函数,也是一个钩子函数。
在一个 test 文件中使用多个分组函数能够帮助梳理不同的逻辑。
describe 内的钩子函数是有作用域的。比如以下代码:
js1import { Counter } from '../utils/counter' 2 3let counter: Counter 4beforeEach(() => { 5 console.log('我的作用域全体') 6 counter = new Counter() 7}) 8describe('测试 加法', () => { 9 beforeEach(() => { 10 console.log('我的作用域在里面') 11 }) 12 it('测试 addOne', () => { 13 counter.addOne() 14 expect(counter.number).toBe(1) 15 }) 16 it('测试 addOne', () => { 17 counter.addOne() 18 expect(counter.number).toBe(1) 19 }) 20}) 21 22describe('测试 减法', () => { 23 it('测试 minusOne', () => { 24 counter.minusOne() 25 expect(counter.number).toBe(-1) 26 }) 27 it('测试 minusOne', () => { 28 counter.minusOne() 29 expect(counter.number).toBe(-1) 30 }) 31})
第一个 beforeEach 是全局的,每一个 describe 函数内的 it 执行前都会执行它。
第二个 beforeEach 只在第一个 describe 内有作用。
tips:可以使用
describe.only或者it.only来仅执行单组或单个测试用例
DoneCallback
现在有一个函数
js1// src/utils/after1000ms.ts 2type AnyFunction = (...rest: any[]) => any 3 4export function after1000ms(callback?: AnyFunction) { 5 // 这里有大量的逻辑,用 for 循环代替 6 for (let i = 0; i < 100000; i++) {} 7 setTimeout(() => { 8 callback && callback() 9 }, 1000) 10}
这个函数会传入一个 callback,并且在 1000 秒后执行 callback。假设我希望知道传入的 callback 有没有被调用,该如何做?
在 it 的回调函数 ProvidesCallback中有一个 cb 参数,即 DoneCallback。我们只需要在调用after1000ms后调用一下这个DoneCallback,就能够通知 Jest 被测试的函数被调用过了。具体代码如下:
js1// src/tests/after1000ms.test.ts 2import { after1000ms } from 'utils/after1000ms' 3describe('after1000ms', () => { 4 it('测试函数被调用了', (done) => { 5 after1000ms(() => { 6 expect('函数被调用了') 7 done() 8 }) 9 }) 10})
useFakeTimers
useFakeTimers 能够模拟 setTimeout 的延迟时间,原本需要 1000ms 的等待时间,使用这个 API 就能够跳过等待时间。
注意,最好在使用 useFakeTimers 时搭配 beforeEach 构造函数,这样能在每次 it 函数执行前重新初始化一下。使各个 it 函数内部的
jest.runAllTimers或advanceTimersByTime不互相影响
-
搭配
jest.runAllTimers比如上面的异步函数,我们需要跳过等待 1000ms,并且验证 callback 函数被运行了一次,可以这么写:
js1beforeEach(() => { 2 jest.useFakeTimers() 3}) 4describe('mock timer', () => { 5 it('timer callback', () => { 6 const after1000ms = (callback: () => void) => { 7 setTimeout(callback, 1000) 8 } 9 const fn = jest.fn() 10 after1000ms(fn) 11 jest.runAllTimers() 12 expect(fn).toBeCalledTimes(1) 13 }) 14})其中
jest.useFakeTimers用来开启使用虚假 timers 功能,jest.runAllTimers用来跳过 所有 setTimeout 的等待时间。 -
搭配
jest.advanceTimersByTimeadvanceTimersByTime顾名思义是快进一些时间。可以理解为把setTimeout的等待时间快进了。还是同样的功能,我们用这个 API 来将时间快进 100 秒。
js1beforeEach(() => { 2 jest.useFakeTimers() 3}) 4describe('mock timer', () => { 5 it('timer callback', () => { 6 const after1000ms = (callback: () => void) => { 7 setTimeout(callback, 100000) 8 } 9 const fn = jest.fn() 10 after1000ms(fn) 11 jest.advanceTimersByTime(100000) 12 expect(fn).toBeCalledTimes(1) 13 }) 14})跟
runAllTimers的区别是这个advanceTimersByTime更加灵活,比如我有两层setTimeoutjs1beforeEach(() => { 2 jest.useFakeTimers() 3}) 4describe('mock timer', () => { 5 it('timer callback', () => { 6 const after1000ms = (callback: () => void) => { 7 setTimeout(() => { 8 callback() 9 setTimeout(() => { 10 callback() 11 }, 1000) 12 }, 1000) 13 } 14 const fn = jest.fn() 15 after1000ms(fn) 16 jest.advanceTimersByTime(1000) 17 expect(fn).toBeCalledTimes(1) 18 }) 19})我们可以把时间快进 1000 秒,这样就能断言出
fn执行了一次。用
runAllTimers会跳过两次setTimeout,没办法只断言第一次setTimeout时fn执行了一次。 -
搭配
jest.runOnlyPendingTimers在开发时,还有一种轮询的场景,下面是一个使用嵌套
setTimeout写的轮询函数。js1function loopSleep(handler: () => void, delay: number) { 2 setTimeout(() => { 3 handler() 4 loopSleep(handler, delay) 5 }, delay) 6}假设我需要测试该轮询函数是否执行了
handler,我可以用mock一个函数。js1beforeEach(() => { 2 jest.useFakeTimers() 3}) 4it('loopSleep', (done) => { 5 const fn = jest.fn() 6 loopSleep(fn, 1000) 7 jest.runAllTimers() 8 expect(fn).toBeCalled() 9})这时会报一个错误

原因是
runAllTimers会接管所有setTimeout,所以会一直循环调用loopSleep。针对这种场景我们可以使用
runOnlyPendingTimers,它只会运行目前挂起的定时器。diff1 it('loopSleep', (done) => { 2 const fn = jest.fn() 3 loopSleep(fn, 1000) 4- jest.runAllTimers() 5+ jest.runOnlyPendingTimers() 6 expect(fn).toBeCalled() 7 }) -
在
setTimeout中执行微任务的情况在开发中,还有一种在定时器内执行微任务的情况,大多数情况下,这个微任务是
promise.then。参考下面的
afterDelay函数:js1function afterDelay(handler: () => void, delay: number) { 2 setTimeout(() => { 3 Promise.resolve().then(() => handler()) 4 }, delay) 5}假设我需要断言
handler是否执行,能不能用以下代码?js1beforeEach(() => { 2 jest.useFakeTimers() 3}) 4it('afterDelay', () => { 5 const fn = jest.fn() 6 afterDelay(fn, 1000) 7 jest.runAllTimers() 8 expect(fn).toBeCalled() 9})很不幸,答案是否定的。

原因是
useFakeTimers替换了浏览器的定时器调度函数,使用useFakeTimers()后的afterDelay里面的setTimeout我们可以看作是同步代码,但是promise.then依然是异步的,所以expect会在then之前执行,而handler在then后被执行,最终导致测试用例的失败。解决的方法也很简单,只需要保证断言在
promise.then之后运行即可。js1it('afterDelay', async () => { 2 const fn = jest.fn() 3 afterDelay(fn, 1000) 4 jest.runAllTimers() 5 // 用 await 阻塞 6 await Promise.resolve() 7 expect(fn).toBeCalled() 8})上面的代码用
await阻塞了Promise.resolve,让整行代码以及下面的代码都变成了异步函数。此时微任务队列中的队首是
promise.then,其次是Promise.resolve,这样就能保证expect在promise.then之后运行。
Mock
mock 函数
jest 提供了一个可供 mock 的 fn 函数, fn 函数可以被捕获到是否执行过。
比如,有一个 runCallback 的函数,我需要捕捉到传给它的 callback 能否被正常调用
js1describe('mock', () => { 2 function runCallback(callback: Function) { 3 callback() 4 } 5 it('callback执行了', () => { 6 const func = jest.fn() 7 expect(runCallback(func)).toBeCalled() 8 }) 9})
同时 jest.fn 还支持接受一个函数,这个函数的返回结果将变成 jest.fn 的返回结果。
js1it('callback执行了', () => { 2 const func = jest.fn(() => 123) 3 runCallback(func) 4 // expect(runCallback(func)).toBeCalled(); 5 console.log('🚀🚀🚀🚀🚀 - func', func.mock) 6}) 7/* { 8 calls: [ [] ], 被调用了多少次 9 contexts: [ undefined ], 10 instances: [ undefined ], 每次调用后 this 的指向 11 invocationCallOrder: [ 1 ], 执行的顺序 12 results: [ { type: 'return', value: 123 } ], 返回结果 13 lastCall: [] 14 } */
通过打印 func.mock,能够获取到函数被调用之后的返回结果,即上面注释中的results属性。
除此之外,还能够通过内部的mockReturnValue来手动指定结果。
js1it('callback执行了', () => { 2 const func = jest.fn() 3 func.mockReturnValueOnce('666') 4 func.mockReturnValue('888') 5 runCallback(func) 6 runCallback(func) 7 // expect(runCallback(func)).toBeCalled(); 8 console.log('🚀🚀🚀🚀🚀 - func', func.mock) 9}) 10/* { 11 calls: [ [], [] ], 12 contexts: [ undefined, undefined ], 13 instances: [ undefined, undefined ], 14 invocationCallOrder: [ 1, 2 ], 15 results: [ 16 { type: 'return', value: '666' }, 17 { type: 'return', value: '888' } 18 ], 19 lastCall: [] 20 } */
以下是常用的能够手动指定mockFn结果的 API:
| mockFn 提供的 API | 用途 |
|---|---|
mockFn.mockReturnValue(value) | Mock 返回值,同步 |
mockFn.mockReturnValueOnce(value) | Mock 返回值,同步,只生效一次 |
mockFn.mockResolvedValue(value) | Mock resolve 返回值,异步 |
mockFn.mockResolvedValueOnce(value) | Mock resolve 返回值,异步,只生效一次 |
mockFn.mockRejectedValue(value) | Mock reject 返回值,异步 |
mockFn.mockRejectedValueOnce(value) | Mock reject 返回值,异步, 只生效一次 |
mock object
jest.spyOn方法能够 mock object(class、object 实例等)的方法。
js1jest.spyOn(object, method, accessType)
例如当前有一个实例对象:
js1const person = { say: () => 'hello', playing: () => 'basketball' }
如果我希望mock 它的say方法,可以用jest.spyOn接管它
js1it('mock person.say', async () => { 2 const person = { say: () => 'hello', playing: () => 'basketball' } 3 jest.spyOn(person, 'say').mockReturnValue('yes') 4 const sayWhat = person.say() 5 const playWhat = person.playing() 6 // say方法被接管了 7 expect(sayWhat).toBe('yes') 8 // playing方法没有被接管 9 expect(playWhat).toBe('basketball') 10})
spyOn方法也可以接受 class,下面是 mock class 的static方法的示例:
js1class People { 2 static getName() { 3 return 'name' 4 } 5} 6it('mock People.getName', async () => { 7 jest.spyOn(People, 'getName').mockReturnValue('yes') 8 const res = People.getName() 9 expect(res).toBe('yes') 10})
除此之外,spyOn方法源码中对类型做了处理,能够根据传参自动获取类型。

从源码可以看出来,它的源码使用泛型 T extends {} 限制和匹配第一个参数为 Object 类型,第二个参数 method则匹配第一个参数内的 key——Key extends keyof T。
如果仅需要mock单个方法,spyOn足够覆盖我们绝大部分的使用场景了。
但如果要mock全体方法,那么还需要全局 mock。
全局 mock
在业务场景中,我们可能会导入一些外部依赖进行测试,针对这些外部依赖,我们可能并不关心它的内部逻辑是怎么样的,我们只需要它返回预期的结果就好。
对于这种场景,我们可以采取全局 mock 的方式,jest 为我们提供了一个 全局 mock 的 API。
js1jest.mock(path, moduleFactory)
它接受两个参数,path 和 moduleFactory,其中 path 是需要 mock 的文件路径,moduleFactory 是这个模块的工厂函数,类型与模块保持一致就行,可以进行更自定义的 mock。
这个 mock 的执行会替代原有的模块内容,接管原有模块暴露出来的方法,并且把它们都变成 mockFn。
下面有一个例子:
jest.mock接管 axios 暴露出来的 API,并且将它们变为 mockFn以便我们断言。
js1import axios from 'axios'; 2 3jest.mock('axios'); 4const mockedAxios = axios as jest.Mocked<typeof axios>; 5describe('mock', () => { 6 function getData() { 7 return axios.get('http://www.dell-lee.com/react/api/demo1.json'); 8 } 9 10 it('getData发送请求了', async () => { 11 mockedAxios.get.mockResolvedValue({ data: 'anything' }); 12 const res = await getData(); 13 // console.log('🚀🚀🚀🚀🚀 - res', res); 14 expect(res).toEqual({ data: 'anything' }); 15 }); 16});
-
我们先用
jest.mock('axios')mock 了 axios。 -
接着用
mockedAxios.get.mockResolvedValue指定 mock 结果为{ data: 'anything' }。 -
最后,我们断言结果为
{ data: 'anything' }表示axios.get请求确实已发送并已拿到请求结果。
这种行为并不会真实发送 axios.get 请求,同时也不在乎返回结果。在不测试后端返回值的场景下非常节省测试时间。
解决类型报错问题
在上面的示例中,有这么一行代码:
js1const mockedAxios = axios as jest.Mocked<typeof axios>;
这个代码主要用来解决 mock axios 时带来的类型报错问题
直接使用axios会有类型报错

因为我们虽然mock了 axios,但是它内部的类型没有得到修改,所以TypeScript会有类型检查的报错。
除了使用强制类型断言外,还能使用jest.mocked方法解决类型报错问题。
js1const { mocked } = jest
现在报错消失了。

mock class
对 class 中的函数或者返回值的测试就跟普通的函数是一样的,比如以下 class:
js1// util/Util.ts 2export default class Util { 3 init() { 4 return 1 5 } 6 start() { 7 return 2 8 } 9}
我要对它的返回结果做测试,则可以单独创建它的测试用例:
js1import Util from '../util/Util' 2 3let util: Util 4beforeAll(() => { 5 util = new Util() 6}) 7 8describe('测试 Util 类', () => { 9 it('测试 util 方法的结果', () => { 10 expect(util.init()).toBe(1) 11 expect(util.start()).toBe(2) 12 }) 13})
然而,class 的调用往往会在大多数其他的函数中一起被调用,比如下面的 foo 函数就在内部使用了 Util 类
js1import Util from './Util' 2 3export function foo() { 4 let util = new Util() 5 util.init() 6 util.start() 7}
我们已经对 Util 构造出来的实例方法产生的结果进行过测试了,所以我不在乎 util.init和 util.start产生的结果。
我只想测试到 foo 函数中 util 是否调用过 init 和 start 函数?这就需要mock class。
首先,我们需要 mock Util,就跟 mock axios 是一样的
js1jest.mock('../util/Util') 2import Util from '../util/Util' 3import { foo } from '../util/foo' 4 5const mockedUtil = jest.mocked(Util)
此时的效果约等于:
js1Util = jest.fn() 2Util.init = jest.fn() 3Util.start = jest.fn()
这时候就能够追踪到它是否被调用过。
js1describe('测试 foo', () => { 2 it('foo 方法中的 init 和 start 方法被调用了', () => { 3 foo() 4 expect(mockedUtil).toBeCalled() 5 expect(mockedUtil.mock.instances[0].init).toBeCalled() 6 expect(mockedUtil.mock.instances[0].start).toBeCalled() 7 }) 8})
我们单独对 class 中的方法结果做出的测试,我们称之为单元测试,只对某个模块功能负责。
对像 foo 这样的,在内部使用了各个功能的函数的测试,我们称之为集成测试。
snapshot
snapshot 是快照的意思。它会生成一个__snapshots__文件夹来保存一个测试快照,这个快照的内容就是执行的结果。
例如,这里有一个生成配置项的函数:
js1// snapshot 2export const generateConfig = () => { 3 return { 4 server: 'localhost', 5 port: 8080, 6 protocol: 'http://', 7 } 8}
如果对它进行测试,我们可以这么写:
js1it('测试 generateConfig', () => { 2 expect(generateConfig()).toEqual({ 3 server: 'localhost', 4 port: 8080, 5 protocol: 'http://', 6 }) 7})
这样是能够通过测试的,但是随着我们配置项的不断更新,我们也不得不同时更新测试用例里的代码,这样就非常麻烦。
我们可以使用toMatchSnapshot匹配器来生成一个快照,这个快照将结果储存起来,每次测试时,都会去跟快照对比,如果对比成功就通过。
假如我们增加了配置,那么同时更新快照即可。
使用快照
按照快照的逻辑,我们将测试用例代码改成这样:
js1it('测试 generateConfig', () => { 2 expect(generateConfig()).toMatchSnapshot() 3})
这时候会生成__snapshots__文件夹,里面会有一个snapshot.test.ts.snap 文件,内容如下:
js1exports[`测试 generateConfig 1`] = ` 2{ 3 "port": 8080, 4 "protocol": "http://", 5 "server": "localhost", 6} 7`
当测试用例运行时,会找到对应文件里的快照,然后将结果与快照内容对比。
更新快照
假设我们确实由于业务的需要,更新了generateConfig的配置
diff1export const generateConfig = () => { 2 return { 3 server: 'localhost', 4 port: 8080, 5 protocol: 'http://', 6+ version: '1.0.0', 7 }; 8};
这时候就需要更新快照,有两种方式:
-
使用 vscode- Jest 插件
vscode 中的 Jest 插件会检测并自动提示是否要更新 snapshot

-
命令行使用 watch 模式-u 模式

随便使用哪种方式,都会生成新的快照
js1// snapshot.test.ts.snap 2exports[`测试 generateConfig 1`] = ` 3{ 4 "port": 8080, 5 "protocol": "http://", 6 "server": "localhost", 7 "version": "1.0.0", 8} 9`
Jest 插件和 u 模式都会更新全部快照,如果想要挨个更新,则可以使用 i 模式
不精准匹配的快照
toMatchSnapshot可以接受一个对象参数,我们通过它可以设置不精确匹配某个快照内容。
例如,现在的 config 更新成如下代码:
js1export const generateConfig = () => { 2 return { 3 server: 'localhost', 4 port: 8080, 5 protocol: 'http://', 6 version: '1.0.0', 7+ time: new Date(), 8 }; 9};
由于 time 属性是不断改变的,快照里的内容则是静态的。每次跟快照对比时肯定都会出错。
这时可以指定一下不精确匹配快照里面的 time,而是只需要判断它是 Date 即可通过测试。
js1it('测试 generateConfig', () => { 2 expect(generateConfig()).toMatchSnapshot({ 3 time: expect.any(Date), 4 }) 5})
如何 Mock 网页地址
虽然通过 jsdom 配置了浏览器环境,但是依然有不少难搞的地方。比如网页路径。
下面有一个函数能够将网页的地址变成 object。
js1// getUrlObject.ts 2export function getUrlObj() { 3 let urlObj = new URL(window.location.href); 4 let obj = {}; 5 for (let key in urlObj) { 6 if (['protocol', 'search', 'hash', 'href', 'pathname'].includes(key)) { 7 Object.assign(obj, { [key]: urlObj[key as keyof typeof urlObj] }); 8 } 9 } 10 return obj; 11}
接着是对其的测试文件
js1// utils/getUrlObject.test.ts 2import { getUrlObj } from 'utils/getUrlObject' 3 4describe('测试网页地址', () => { 5 it('将网页地址映射成正确的 object 对象', () => { 6 window.location.href = 'https://baidu.com/hello-jest?name=qyx#age' 7 expect(getUrlObj()).toEqual({ 8 href: 'https://baidu.com/hello-jest?name=qyx#age', 9 search: '?name=qyx', 10 hash: '#age', 11 protocol: 'https:', 12 pathname: '/hello-jest', 13 }) 14 }) 15})
然而执行 test 后会得到以下报错:

这说明 jsdom 不能直接用window.location.href这个 API 测试。
jest-location-mock
有一个优雅的实现方法:jest-location-mock,这个包提供修改和获取 url 的功能。
首先安装
bash1npm i -D jest-location-mock
安装完包之后,需要在 jest 测试框架启动前装载上去。将之前注释过的setupFilesAfterEnv配置放开
json1 setupFilesAfterEnv: ['./src/tests/jest-setup.ts'],
然后在jest-setup.ts中引入该包
js1// 使用扩展包来 Mock 'window.location' 2import 'jest-location-mock'
最后运行npm run test得到结果

小结
对于网页地址的修改和测试可以使用 jest-location-mock 来扩展 jsdom 的能力。
扩展的使用方法是先配置setupFilesAfterEnv 选项,然后在 setup 文件中引入,使其变成全局 Mock。
TDD
TDD(测试驱动开发)是一种非常好的开发模式,原理是:先写测试,再写业务代码,当所有测试用例都通过后,业务代码的实现也呼之欲出了。

- 红色部分:在添加新功能前先写测试用例,同时这也是一个帮助缕逻辑的过程
- 绿色部分:开始添加业务代码来让测试通过
- 重构部分:回头审视自己的代码,将其重构成可读性和维护性高的代码。(同时重构后,测试用例会帮助检查重构后的逻辑是否有漏洞)
- ...重复上述过程
TDD 的主要作用并不是保证代码质量,而是创造一个更好的开发环境,在此基础上保证代码的主要逻辑正确 ✅。
实战一个 search 转化功能
下面有一个需求:
实现一个函数,该函数能够获取 url,然后将其 search 转化成对象。如果 search 不存在,则返回空对象
如果有传递参数,且参数为
xx=xxx&yy=yyy字符串时,能够将其解析成{xx:xxx,yy:yyy}
https://baidu.com?name=qyx===>{ name: 'qyx' }https://baidu.com===>{}?name=qyx&age=30===>{ name: 'qyx',age: 30 }
根据需求,我们先写好测试文件:
js1import { transformSearch } from 'utils/transformSearch' 2 3describe('将url 的 searchString 变成 object', () => { 4 it('能正确转化成对象', () => { 5 window.location.href = 'https://baidu.com?name=qyx&age=30#hello-jest' 6 expect(transformSearch()).toEqual({ name: 'qyx', age: '30' }) 7 }) 8 9 it('无searchString 但有 ? 时解析出空对象', () => { 10 window.location.href = 'https://baidu.com?#hello-jest' 11 expect(transformSearch()).toEqual({}) 12 }) 13 14 it('无searchString 无 ? 时解析出空对象', () => { 15 window.location.href = 'https://baidu.com#hello-jest' 16 expect(transformSearch()).toEqual({}) 17 }) 18 19 it('传入?name=qyx&age=30解析成正确对象', () => { 20 expect(transformSearch('?name=qyx&age=30')).toEqual({ 21 name: 'qyx', 22 age: '30', 23 }) 24 }) 25 26 it('传入name=qyx&age=30解析成正确对象', () => { 27 expect(transformSearch('name=qyx&age=30')).toEqual({ 28 name: 'qyx', 29 age: '30', 30 }) 31 }) 32 33 it('传入https://baidu.com?name=qyx&age=30#hello-jest解析成正确对象', () => { 34 expect(transformSearch('https://baidu.com?name=qyx&age=30#hello-jest')).toEqual({ name: 'qyx', age: '30' }) 35 }) 36 37 it('传入空字符串解析成空对象', () => { 38 expect(transformSearch('')).toEqual({}) 39 }) 40})
通过 window.location.href来转化成对象,我们很容易写出来:
js1export function transformSearch(searchString?: string) { 2 if (searchString) { 3 } else { 4 let searchString = new URL(window.location.href).search 5 searchString = searchString.replace(/\?/g, '') 6 return Object.fromEntries(new URLSearchParams(searchString).entries()) 7 } 8}
测试后能通过大部分用例,只剩通过 searchString 参数的逻辑是失败的。通过用例,我们能分析出用户可能会传递三种情况:
- 带问号的查询字符串
- 不带问号的查询字符串
- 带问号的整个 URL 连接
- 传入一个空字符串
整个 URL 可以传递给 new URL()来构造一个 URL 对象,而其他的非 url 传进去会报错。所以我们可以这么写:
js1export function transformSearch(searchString?: string) { 2 if (searchString) { 3 try { 4 let searchStr = new URL(searchString).search 5 searchStr = searchStr.replace(/\?/g, '') 6 return Object.fromEntries(new URLSearchParams(searchStr).entries()) 7 } catch (error) { 8 let searchStr = searchString.replace(/\?/g, '') 9 return Object.fromEntries(new URLSearchParams(searchStr).entries()) 10 } 11 } else { 12 let searchString = new URL(window.location.href).search 13 searchString = searchString.replace(/\?/g, '') 14 return Object.fromEntries(new URLSearchParams(searchString).entries()) 15 } 16}
执行npm run test查看到所有用例已经通过

现在可以优化代码啦,将它们共有的逻辑抽离出来即可:
js1function getObjFromSearch(search: string) { 2 let searchString = search.replace(/\?/g, '') 3 return Object.fromEntries(new URLSearchParams(searchString).entries()) 4} 5export function transformSearch(searchString?: string) { 6 if (searchString) { 7 try { 8 return getObjFromSearch(new URL(searchString).search) 9 } catch (error) { 10 return getObjFromSearch(searchString) 11 } 12 } 13 return getObjFromSearch(new URL(window.location.href).search) 14}
重新执行npm run test,所有测试用例依然通过了。
即使重构再多代码,测试用例也能帮助我们兜底,避免重构后影响主逻辑的尴尬。
小结
有两种主要开发模式:
- TDD - 测试驱动开发,即先写测试用例,再根据它们补充业务代码
- BDD - 先写业务,再对重要的部分补充测试
TDD 的好处是我们在前期不仅能够对要做的功能有一个清晰的逻辑整理,还能够在后期重构代码时对主逻辑兜底,不陷入重构后主逻辑出问题的境地。
github CI
代码提交到 github 后,往往需要通过 test 测试才能合并到主分支,所以这里贴一下 giehub workflow 的配置:
bash1name: Test 2on: push 3jobs: 4 test: 5 runs-on: ubuntu-latest 6 steps: 7 - uses: actions/checkout@v2 8 - uses: codecov/codecov-action@v3 9 - name: Install modules 10 run: yarn 11 - name: Upload coverage reports to Codecov 12 run: npx codecov --token=${{ secrets.CODECOV_TOKEN }} --file=coverage.json 13 - name: Run tests 14 run: yarn test
codecov: token: