Jest 基础

创建项目

建项目

bash
1mkdir jest-ts
2cd jest-ts
3npm init -y

安装 jest、ts-node 和 typescript 以及一些类型声明文件

bash
1npm i -D jest @types/jest @jest/types ts-node typescript
2npx jest --init

--init是用jest-cli初始化 jest 配置文件,根据提示和自己的需求配置即可。

这里贴一下我的配置

image-20230224210849945

解释一下:

  • 第三个选项——前端测试会涉及 dom 操作,所以用 browser-like,如果是 node 环境,则可以选 node。
  • 第四个选项——覆盖率报告,需要用到
  • 第五个选项——用 babel 可以避免某些兼容性问题
  • 测试完成后自动清理 mock,实例等缓存以避免用例之间互相影响

即使选项不一致也没问题,因为后面可以修改配置文件来修改上面的选择配置

执行完之后,会生成jest.config.js的配置文件。

TS Support

想要支持 TypeScript,就必须转译 ts 代码。

Jest 本身不支持代码转译。在执行时会调用转译器/编译器做代码转译。

这样就能够对 TypeScript 转译成 JS 代码从而让 Jest 支持对 TypeScript 的测试。

转译器有两种,我主要使用 babel以减少某些兼容性导致的影响。

使用 ts-jest

bash
1npm i -D ts-jest
  • 安装 ts-jest 。ts-jest 用来转译 TypeScript 的代码
  • 按照 jest 类型声明
  • ts-node 用来启动 TypeScript 代码

ts-jest 的大版本要和 jest 的一致,否则容易产生兼容问题。

生成 TypeScript 配置文件

bash
1tsc --init

在jest.config.js中添加配置:

js
1module.exports = {
2  transform: {
3    '^.+\\.ts?$': 'ts-jest',
4  },
5  ...
6  }

babal 转译

bash
1npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript

这里主要安装 babel 的预设插件和 react 以及 typescript 的预设插件。

安装完成后,我们在根目录创建一个 babel.config.js 文件用于 babel 的配置

js
1module.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 版本需要额外安装依赖

bash
1npm i -D jest-environment-jsdom

jest-environment-jsdom可以看成是独立的包,它内部实现了浏览器的大部分功能,可以看作是 Jest 版本的仿浏览器宿主环境。

当这个包安装好之后,需要让 Jest 识别到这个环境,有两种不同的方法:

  1. 在 jest.config.js中配置

    js
    1module.exports = {
    2  testEnvironment: 'jest-environment-jsdom',
    3  ...
    4  }
  2. 将这个包作为全局 mock

    在 src 目录下创建 test 目录,并创建 setup.ts 文件,内容如下:

    js
    1// setup.ts
    2import 'jest-environment-jsdom'

    在jest.config.js中配置

    js
    1module.exports = {
    2  setupFilesAfterEnv: ['./src/test/setup.ts'],
    3  ...
    4  }

    这个选项是用来执行装载文件的——当 Jest 测试环境启动后,会将指定路径下的文件运行一遍,一般用来装载全局 mock 环境。

    很多第三方的 mock 插件安装后都会经过 setup 文件给 Jest 环境装载某些功能。

    跟 setupFilesAfterEnv 类似还有一个 setupFiles 选项,它也可以用来设置全局 Mock 数据。

    image-20221016031742426

    setupFiles 比 setupFilesAfterEnv更早执行,所以也可以用来设置 Mock 全局变量。

    不同之处在于,由于setupFilesAfterEnv是在引入测试框架 Jest 之后运行的,所以能够在此引入和配置Jset/Jasmine的插件。

    如果在setupFiles中添加 Jest 的扩展和插件,就会得到报错。

    这里有个 issue 可以参考

额外扩展

  1. 额外的扩展名识别

    因为 Jest 不使用 Webpack 等打包工具,因此它不知道如何加载除 js/jsx 之外的其他文件扩展名,所以我们需要为它加一个转换器。

    js
    1// jest.config.ts
    2export default {
    3  // ... other config
    4  transform: {
    5    // ...
    6    '^.+.(js|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
    7  },
    8}
  2. SVG mock

    我们项目中可能会有用到 svg 等图片,这个对于 Jest 是无法识别的,我们需要对它进行 mock,返回相同的输出结果。

    js
    1// jest.config.ts
    2export default {
    3  // ... other config
    4  transform: {
    5    // ...
    6    '^.+.svg$': '<rootDir>/mock/svg-transform.js',
    7  },
    8}
    js
    1// mock/svg-transform.js
    2module.exports = {
    3  process() {
    4    return { code: 'module.exports = {};' }
    5  },
    6  getCacheKey() {
    7    return 'svgTransform' // SVG固定返回这个字符串
    8  },
    9}
  3. CSS 代理

    Jest 本身不知道如何处理不同扩展的文件,我们可以通过配置代理的方式,告诉 Jest 将此对象模拟为导入的 CSS 模块。

    bash
    1npm install --save-dev identity-obj-proxy
    js
    1// jest.config.ts
    2export default {
    3  // ... other config
    4  moduleNameMapper: {
    5    '.(css|less|sass|scss)$': 'identity-obj-proxy',
    6  },
    7}

测试 TypeScript 代码

测试 sum 函数

js
1// src/utils/sum.ts
2export function sum(a: number, b: number) {
3  return a + b
4}
js
1// 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环境

js
1// src/utils/dom.ts
2export function addDiv() {
3  const div = document.createElement('div')
4  document.body.append(div)
5}
js
1// 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})

项目结构

bash
1├── 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

得到覆盖率报告

image-20221105232754102

自动运行 jest

如果希望能够在修改 test 文件后自动运行 jest,我们需要在package.json的脚本命令后加上--watchAll:

json
1  "scripts": {
2    "test": "jest --watchAll"
3  },

--watchAll 会在变更后测试所有的文件。

如果只希望测试变更过的文件,则可以改成

json
1  "scripts": {
2    "test": "jest --watch"
3  },

在该模式下,会自动运行 o 模式 ——参见 测试时命令模式

单文件测试

如果只想要测试一个 js 文件,则使用以下命令:

bash
1npm run test <文件相对路径>

例如:

bash
1 npm run test ./src/test/sum.test.ts

覆盖率报告

终端展示的是覆盖率情况,还有更加详细的覆盖率报告。

覆盖率报告在新生成的coverage目录中,有不同格式的覆盖率报告

bash
1├── 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 来查看网页版本的报告

js
1cd coverage/lcov-report
2hs -c-1 . // 用 http-server 打开index.html

image-20221105234317849

路径别名

所谓路径别名就是我希望从这个引入:

js
1import { sum } from '../util/sum'

换成这个引入

js
1import { sum } from 'util/sum'

实际上就是把utils映射成src/utils。

首先要在 moduleDirectories中配置

json
1// jest.config.js
2 moduleDirectories: ['node_modules', 'src'],

这里的意思是让 jest 可以直接到 node_modules 或者 src 下去找文件。

其次在tsconfig中配置 path

js
1    "baseUrl": "./",
2    "paths": {
3      "util/*": ["src/util/*"]
4    },

这样 ts 文件就能够按照 paths 的配置去对应的映射路径中找到该文件。

如果要改成这样引入:

js
1import { addDiv } from '@/util/dom'

@代表src。

jest 想要识别这个符号就需要在moduleNameMapper中配置:

js
1modulex.exports = {
2  moduleNameMapper: { '@/(.*)': '<rootDir>/src/$1' },
3}

这是 jest 的路径匹配。

然后 tsconfig 中也加入对@的映射。

js
1    "baseUrl": "./",
2    "paths": {
3      "utils/*": ["src/utils/*"],
4      "@/*": ["src/*"]
5    },

此时我们就可以用两种方式引入啦

js
1import { 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中配置一个脚本:

js
1    "debug": "node --inspect ./node_modules/jest/bin/jest --runInBand --no-cache --no-watchman"

打开 chrome 浏览器,在地址栏输入

chrome://inspect/

点击这里:

image-20230224234051591

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

2023-02-24.234344

然后就可以愉快地打断点+调试代码啦。

命令模式

在--watch<All>的情况下,会有一种交互型的命令模式。

image-20221023223817345

  • f 只跑失败的用例
  • o 只测试改变的文件(配合 git 使用,相当于 --watch)
  • p 根据正则表达式测试对应的文件名里的用例
  • t 根据正则表达式测试 test 函数里对应的 name 的用例
  • q 退出 watch 模式
  • i 以交互方式运行失败的测试用例
  • enter 重新触发一次测试
  • a 测试 all 文件(相当于 --watchAll)

这些命令模式主要功能是更好地支撑用户对测试的需求。

【强烈推荐】编辑器还可以下载 Jest 官方插件,用可视化的界面支撑 Jest 测试,例如 VSCode 平台下的Jest

断言匹配器

Jest 使用断言匹配器来帮助我们编写基本的测试预期,主要是对一些数据的预期判断。

匹配器(matcher)有很多种,常见的有:

  • toBe 匹配简单数据类型是否相等

    js
    1expect(1 + 2).toBe(3)
  • toEqual 深度匹配对象内容相等

    js
    1expect({ age: 12 }).toEqual({ age: 12 })
  • toBeNull 是否为 null

    js
    1let n = null
    2expect(n).toBeNull()
  • toBeUndefined 是否为 undefined

    js
    1let u = undefined
    2expect(u).toBeUndefined()
  • toBeFalsy 是否为 假值

    js
    1let s = 0
    2expect(s).toBeFalsy()
  • toBeDefined 是否为非 undefined

    js
    1let u = null
    2expect(u).toBeDefined()
  • toBeTruthy 是否为 真值

    js
    1let t = 1
    2expect(t).toBeTruthy()
  • not.to 匹配器取反

    js
    1let t = 1
    2expect(t).not.toBeUndefined()
  • toBeGreaterThan 比某个大

  • toBeLessThan 比某个小

  • toBeGreaterThanOrEqual 大于或者等于

  • toBeLessThanOrEqual 小于或者等于

  • toBeCloseTo 是否接近于,用于浮点数的计算

    js
    1expect(0.1 + 0.2).toBeCloseTo(0.3)
  • toMatch 字符串是否匹配

  • js
    1expect('hello world').toMatch(/hello/)
  • toContain 数组或者 Set 结构是否包含一个项(简单数据类型)

    js
    1expect(['123', '456']).toContain('123')
  • toContainEqual 数组或者 Set 结构是否包含一个项(复杂数据类型)

    js
    1expect([{ name: 'qxy' }]).toContainEqual({ name: 'qxy' })
  • toThrow 是否抛出一个异常,可以传入字符串检查抛出的内容

    js
    1expect(throwError).toThrowError()
    2expect(throwError).toThrowError('get an error')
  • toMatchObject 匹配对象的内容

    js
    1expect({ name: 'qyx', age: 12 }).toMatchObject({ age: 12 })
    2expect([{ name: 'qyx', age: 12 }]).toMatchObject([{ age: 12 }])

...等等

测试异步代码

测试 callback

js
1// 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}
js
1// 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

js
1import 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

js
1describe('测试 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

js
1describe('测试 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

js
1import 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

js
1describe('测试异步函数', () => {
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

js
1describe('测试异步函数', () => {
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 类

js
1export 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 的方法

当我对这两个函数进行测试

js
1import { 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 钩子函数。

js
1describe('测试 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 内的钩子函数是有作用域的。比如以下代码:

js
1import { 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

现在有一个函数

js
1// 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 被测试的函数被调用过了。具体代码如下:

js
1// 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 函数被运行了一次,可以这么写:

    js
    1beforeEach(() => {
    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.advanceTimersByTime

    advanceTimersByTime顾名思义是快进一些时间。可以理解为把 setTimeout的等待时间快进了。

    还是同样的功能,我们用这个 API 来将时间快进 100 秒。

    js
    1beforeEach(() => {
    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 更加灵活,比如我有两层 setTimeout

    js
    1beforeEach(() => {
    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写的轮询函数。

    js
    1function loopSleep(handler: () => void, delay: number) {
    2  setTimeout(() => {
    3    handler()
    4    loopSleep(handler, delay)
    5  }, delay)
    6}

    假设我需要测试该轮询函数是否执行了 handler,我可以用mock一个函数。

    js
    1beforeEach(() => {
    2  jest.useFakeTimers()
    3})
    4it('loopSleep', (done) => {
    5  const fn = jest.fn()
    6  loopSleep(fn, 1000)
    7  jest.runAllTimers()
    8  expect(fn).toBeCalled()
    9})

    这时会报一个错误

    image-20230302202206967

    原因是 runAllTimers会接管所有setTimeout,所以会一直循环调用loopSleep。

    针对这种场景我们可以使用runOnlyPendingTimers,它只会运行目前挂起的定时器。

    diff
    1  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函数:

    js
    1function afterDelay(handler: () => void, delay: number) {
    2  setTimeout(() => {
    3    Promise.resolve().then(() => handler())
    4  }, delay)
    5}

    假设我需要断言handler是否执行,能不能用以下代码?

    js
    1beforeEach(() => {
    2  jest.useFakeTimers()
    3})
    4it('afterDelay', () => {
    5  const fn = jest.fn()
    6  afterDelay(fn, 1000)
    7  jest.runAllTimers()
    8  expect(fn).toBeCalled()
    9})

    很不幸,答案是否定的。

    image-20230302204230799

    原因是useFakeTimers替换了浏览器的定时器调度函数,使用useFakeTimers()后的afterDelay里面的setTimeout我们可以看作是同步代码,但是promise.then依然是异步的,所以expect会在then之前执行,而handler在then后被执行,最终导致测试用例的失败。

    解决的方法也很简单,只需要保证断言在promise.then之后运行即可。

    js
    1it('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 能否被正常调用

js
1describe('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 的返回结果。

js
1it('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来手动指定结果。

js
1it('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 实例等)的方法。

js
1jest.spyOn(object, method, accessType)

例如当前有一个实例对象:

js
1const person = { say: () => 'hello', playing: () => 'basketball' }

如果我希望mock 它的say方法,可以用jest.spyOn接管它

js
1it('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方法的示例:

js
1class 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方法源码中对类型做了处理,能够根据传参自动获取类型。

image-20230303224144067

从源码可以看出来,它的源码使用泛型 T extends {} 限制和匹配第一个参数为 Object 类型,第二个参数 method则匹配第一个参数内的 key——Key extends keyof T。

如果仅需要mock单个方法,spyOn足够覆盖我们绝大部分的使用场景了。

但如果要mock全体方法,那么还需要全局 mock。

全局 mock

在业务场景中,我们可能会导入一些外部依赖进行测试,针对这些外部依赖,我们可能并不关心它的内部逻辑是怎么样的,我们只需要它返回预期的结果就好。

对于这种场景,我们可以采取全局 mock 的方式,jest 为我们提供了一个 全局 mock 的 API。

js
1jest.mock(path, moduleFactory)

它接受两个参数,path 和 moduleFactory,其中 path 是需要 mock 的文件路径,moduleFactory 是这个模块的工厂函数,类型与模块保持一致就行,可以进行更自定义的 mock。

这个 mock 的执行会替代原有的模块内容,接管原有模块暴露出来的方法,并且把它们都变成 mockFn。

下面有一个例子:

jest.mock接管 axios 暴露出来的 API,并且将它们变为 mockFn以便我们断言。

js
1import 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});
  1. 我们先用 jest.mock('axios')mock 了 axios。

  2. 接着用mockedAxios.get.mockResolvedValue指定 mock 结果为{ data: 'anything' }。

  3. 最后,我们断言结果为{ data: 'anything' }表示axios.get请求确实已发送并已拿到请求结果。

这种行为并不会真实发送 axios.get 请求,同时也不在乎返回结果。在不测试后端返回值的场景下非常节省测试时间。

解决类型报错问题

在上面的示例中,有这么一行代码:

js
1const mockedAxios = axios as jest.Mocked<typeof axios>;

这个代码主要用来解决 mock axios 时带来的类型报错问题

直接使用axios会有类型报错

image-20230303210617860

因为我们虽然mock了 axios,但是它内部的类型没有得到修改,所以TypeScript会有类型检查的报错。

除了使用强制类型断言外,还能使用jest.mocked方法解决类型报错问题。

js
1const { mocked } = jest

现在报错消失了。

image-20230303211050141

mock class

对 class 中的函数或者返回值的测试就跟普通的函数是一样的,比如以下 class:

js
1// util/Util.ts
2export default class Util {
3  init() {
4    return 1
5  }
6  start() {
7    return 2
8  }
9}

我要对它的返回结果做测试,则可以单独创建它的测试用例:

js
1import 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 类

js
1import 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 是一样的

js
1jest.mock('../util/Util')
2import Util from '../util/Util'
3import { foo } from '../util/foo'
4
5const mockedUtil = jest.mocked(Util)

此时的效果约等于:

js
1Util = jest.fn()
2Util.init = jest.fn()
3Util.start = jest.fn()

这时候就能够追踪到它是否被调用过。

js
1describe('测试 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__文件夹来保存一个测试快照,这个快照的内容就是执行的结果。

例如,这里有一个生成配置项的函数:

js
1// snapshot
2export const generateConfig = () => {
3  return {
4    server: 'localhost',
5    port: 8080,
6    protocol: 'http://',
7  }
8}

如果对它进行测试,我们可以这么写:

js
1it('测试 generateConfig', () => {
2  expect(generateConfig()).toEqual({
3    server: 'localhost',
4    port: 8080,
5    protocol: 'http://',
6  })
7})

这样是能够通过测试的,但是随着我们配置项的不断更新,我们也不得不同时更新测试用例里的代码,这样就非常麻烦。

我们可以使用toMatchSnapshot匹配器来生成一个快照,这个快照将结果储存起来,每次测试时,都会去跟快照对比,如果对比成功就通过。

假如我们增加了配置,那么同时更新快照即可。

使用快照

按照快照的逻辑,我们将测试用例代码改成这样:

js
1it('测试 generateConfig', () => {
2  expect(generateConfig()).toMatchSnapshot()
3})

这时候会生成__snapshots__文件夹,里面会有一个snapshot.test.ts.snap 文件,内容如下:

js
1exports[`测试 generateConfig 1`] = `
2{
3  "port": 8080,
4  "protocol": "http://",
5  "server": "localhost",
6}
7`

当测试用例运行时,会找到对应文件里的快照,然后将结果与快照内容对比。

更新快照

假设我们确实由于业务的需要,更新了generateConfig的配置

diff
1export const generateConfig = () => {
2  return {
3    server: 'localhost',
4    port: 8080,
5    protocol: 'http://',
6+    version: '1.0.0',
7  };
8};

这时候就需要更新快照,有两种方式:

  1. 使用 vscode- Jest 插件

    vscode 中的 Jest 插件会检测并自动提示是否要更新 snapshot

    image-20221029173926654

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

    image-20221029174105309

随便使用哪种方式,都会生成新的快照

js
1// 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 更新成如下代码:

js
1export 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 即可通过测试。

js
1it('测试 generateConfig', () => {
2  expect(generateConfig()).toMatchSnapshot({
3    time: expect.any(Date),
4  })
5})

如何 Mock 网页地址

虽然通过 jsdom 配置了浏览器环境,但是依然有不少难搞的地方。比如网页路径。

下面有一个函数能够将网页的地址变成 object。

js
1// 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}

接着是对其的测试文件

js
1// 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 后会得到以下报错:

image-20221016085200589

这说明 jsdom 不能直接用window.location.href这个 API 测试。

jest-location-mock

有一个优雅的实现方法:jest-location-mock,这个包提供修改和获取 url 的功能。

首先安装

bash
1npm i -D jest-location-mock

安装完包之后,需要在 jest 测试框架启动前装载上去。将之前注释过的setupFilesAfterEnv配置放开

json
1 setupFilesAfterEnv: ['./src/tests/jest-setup.ts'],

然后在jest-setup.ts中引入该包

js
1// 使用扩展包来 Mock 'window.location'
2import 'jest-location-mock'

最后运行npm run test得到结果

image-20221016092328983

小结

对于网页地址的修改和测试可以使用 jest-location-mock 来扩展 jsdom 的能力。

扩展的使用方法是先配置setupFilesAfterEnv 选项,然后在 setup 文件中引入,使其变成全局 Mock。

TDD

TDD(测试驱动开发)是一种非常好的开发模式,原理是:先写测试,再写业务代码,当所有测试用例都通过后,业务代码的实现也呼之欲出了。

img

  • 红色部分:在添加新功能前先写测试用例,同时这也是一个帮助缕逻辑的过程
  • 绿色部分:开始添加业务代码来让测试通过
  • 重构部分:回头审视自己的代码,将其重构成可读性和维护性高的代码。(同时重构后,测试用例会帮助检查重构后的逻辑是否有漏洞)
  • ...重复上述过程

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 }

根据需求,我们先写好测试文件:

js
1import { 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来转化成对象,我们很容易写出来:

js
1export 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 参数的逻辑是失败的。通过用例,我们能分析出用户可能会传递三种情况:

  1. 带问号的查询字符串
  2. 不带问号的查询字符串
  3. 带问号的整个 URL 连接
  4. 传入一个空字符串

整个 URL 可以传递给 new URL()来构造一个 URL 对象,而其他的非 url 传进去会报错。所以我们可以这么写:

js
1export 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查看到所有用例已经通过

image-20221017214314575

现在可以优化代码啦,将它们共有的逻辑抽离出来即可:

js
1function 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 的配置:

bash
1name: 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: