Vitest 基础学习记录:从第一个测试到 mock

这一年多来我开始使用 vibe coding,也开始思考如何限制和界定 agent 的行为,从而保证代码质量。对我来说,自测是一个不错的办法。

我突然发现我以前都没学习过自测,索性简单学习下,看看能做什么,能做到什么程度。

本篇文章为个人学习笔记,也可以作为你简单快速了解 vitest 这个框架的文章。

初体验

快速起步 - Vitest 中文官方

官方有个最小实践,我就不全文复制了,简单来说就是,安装-引入-编写测试代码-运行测试。

  1. 安装
1
npm install -D vitest
  1. 引入

package.json 中引入指令

title:package.json
1
2
3
4
5
{
"scripts": {
"test": "vitest"
}
}
  1. 编写测试代码

sum.js

title:sum.js
1
2
3
export function sum(a, b) {
return a + b
}

sum.test.js

title:sum.test.js
1
2
3
4
5
6
import { expect, test } from 'vitest'
import { sum } from './sum.js'

test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})

这个 test 文件怎么理解呢?

第一个参数是测试名称,主要用于测试报告和失败定位;第二个参数是实际执行的测试函数。

sum(1, 2) 会先执行并得到实际结果,然后传给 expectexpect 接收这个“实际值”,并返回一组断言方法;这里通过 .toBe(3) 判断实际值是否和期望值 3 相等。

  1. 执行
1
npm run test

执行这条命令,vitest 会从你的项目代码中找到所有测试文件的代码执行。

默认情况下,Vitest 会查找文件名中包含 .test..spec. 的任何文件。

https://cn.vitest.dev/guide/learn/writing-tests.html#test-files

这就是一个上手 vitest 的最小实践,还是很简单的,其实就是去写一些你认为正确的入参和它对应的出参,自动化执行,最后给出结果告诉你对了多少个,通过率多少,哪里不对等等。(当然肯定不止这么简单。)

简单介绍 vitest

基于 Vite 的一个测试框架,兼容 Jest,具有 watch 模式,支持 TypeScript 。

匹配器

前面我们写到 expect(sum(1, 2)).toBe(3) ,这里就是使用 toBe 这个匹配器,去检查值是否完全等于 3

toBe 是恒等于,适用于数字、布尔值和字符串等原始类型。

如果要检查数组或对象就需要用到 toEqual 匹配器,它会递归的比较对象或数组的每个字段或元素。

1
2
3
4
5
6
7
test('toBe vs toEqual', () => {
const a = { foo: 'bar' }
const b = { foo: 'bar' }

expect(a).toBe(b) // 失败,因为 a 和 b 是不同的对象引用
expect(a).toEqual(b) // 成功,因为它们的内容相同
})

再进阶一点还有 toStrictEqual ,它比 toEqual 更严格,还会检查 undefined 属性和检查对象原型链(是否具有相同的类型)。

1
2
3
4
5
6
7
8
9
10
11
12
test('toEqual vs toStrictEqual', () => {
expect({ a: 1 }).toEqual({ a: 1, b: undefined }) // 成功
expect({ a: 1 }).not.toStrictEqual({ a: 1, b: undefined }) // 成功,因为 toStrictEqual 会检查对象的属性是否完全相同,包括未定义的属性

class User {
constructor(name) {
this.name = name
}
}
expect(new User('Peter')).toEqual({ name: 'Peter' }) // 成功,因为 toEqual 只检查对象的内容
expect(new User('Peter')).not.toStrictEqual({ name: 'Peter' }) // 成功,因为 toStrictEqual 会检查对象的原型链
})

not.匹配器,也就是不成立的情况。

除此之外,还有很多。

– 来自 https://cn.vitest.dev/guide/learn/matchers.htm,其他数字大小比较、字符串 match等也可以参考该链接。

更多匹配器可以从这里查找-https://cn.vitest.dev/api/expect.html

测试异步代码

前面测试的都是同步代码,那异步代码也很简单,只需要将测试函数变为 async(也就是 test 的第二个入参),如下:

1
2
3
4
5
6
7
8
function fetchUser(id) {
return Promise.resolve({ id, name: 'Alice' })
}

test('fetches user by id', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('Alice')
})

当然也可以直接对异步函数使用

1
2
3
4
5
6
7
test('resolves to Alice', async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ name: 'Alice' })
})

test('rejects with an error', async () => {
await expect(fetchInvalidUser()).rejects.toThrow('User not found')
})

初始化和清理

重复初始化

在测试时,经常需要一些初始化好的数据,但是一些测试函数会对测试数据进行增删改查,影响下一个测试函数的进行,所以在测试函数开始的前后,需要进行数据初始化和清理,这就引入了beforeEachafterEach

顾名思义,beforeEach 会在文件中的每个测试之前运行,而 afterEach 会在每个测试之后运行,即使测试失败也是如此。这使得它们非常适合确保每个测试都从一个已知的初始状态开始。

注意这里是“每个测试”,而不只是运行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let items

beforeEach(() => {
// 在每个测试之前运行的代码
items = ['apple', 'banana', 'cherry']
})

afterEach(() => {
// 在每个测试之后运行的代码
items = []
})

test('items starts with 3 fruits', () => {
expect(items).toHaveLength(3)
})

test('can remove an item', () => {
items.pop()
expect(items).toHaveLength(2)
})

test('can add an item', () => {
items.push('date')
expect(items).toHaveLength(4)
// 运行此测试前,beforeEach 会将数组重置为 3 项,
// 由此可见,上一个测试对数组的修改不会影响当前测试。
})

一次性初始化

前面我们提到,这 2 个钩子每个测试函数运行之前之后都会执行一次。面对一些耗时或者只需要初始化、清理一次的动作,就显得不合适,这时就需要用到 beforeAllafterAll

它们在整个文件运行期间只执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let dbConnection

beforeAll(() => {
// 在所有测试之前运行的代码
dbConnection = { connected: true }
})

afterAll(() => {
// 在所有测试之后运行的代码
dbConnection = null
})

test('dbConnection is established', () => {
expect(dbConnection).toEqual({ connected: true })
})

describe 作用域

可以使用 describe 进行作用域区分,内部定义的钩子(beforeEach等)仅适用于该块内部的测试。当然了,顶层的钩子适用于文件中的每个测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 顶层钩子 - 适用于文件中所有测试
beforeEach(() => {
console.log('每个测试都会执行这个');
});

// 用户模块测试 - 独立作用域 A
describe('User API', () => {
let user // 只在这个 describe 内有效

// 只对当前 describe 内的所有测试生效
beforeEach(() => {
user = { id: 1, name: 'John' }
})

test('should return user name', () => {
expect(user.name).toBe('John')
})
})

// 产品模块测试 - 独立作用域 B
describe('Product API', () => {
let product // 只在这个 describe 内有效,和上面的 user 互不影响

// 只对当前 describe 内的测试生效,不会影响 User API 的测试
beforeEach(() => {
product = { id: 1, price: 100 }
})

test('should return product price', () => {
expect(product.price).toBe(100)
})
})

另外,describe 还支持嵌套。

执行顺序

进入先 all 后 each ,退出反之。each 先外后内,退出反之。

也可以说是:先全局后局部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let order = 0

beforeAll(() => console.log(++order, 'beforeAll'))
beforeEach(() => console.log(++order, 'beforeEach (outer)'))
afterEach(() => console.log(++order, 'afterEach (outer)'))
afterAll(() => console.log(++order, 'afterAll'))

describe('suite', () => {
beforeEach(() => console.log(++order, 'beforeEach (inner)'))
afterEach(() => console.log(++order, 'afterEach (inner)'))

test('test 1', () => {
console.log(++order, 'test 1')
})

test('test 2', () => {
console.log(++order, 'test 2')
})
})

结果

1
2
3
4
5
6
7
8
9
10
11
12
1 beforeAll
2 beforeEach (outer)
3 beforeEach (inner)
4 test 1
5 afterEach (inner)
6 afterEach (outer)
7 beforeEach (outer)
8 beforeEach (inner)
9 test 2
10 afterEach (inner)
11 afterEach (outer)
12 afterAll

注意,控制台不一定按照这个顺序 log 出来,但是1和 beforeAll 对应,12 和 afterAll 对应,其他按顺序就行,因为控制台输出顺序 ≠ 代码执行顺序,Vitest 会缓冲输出。

模拟函数

模拟返回值。常用于模拟难以产生的错误或防止发起网络请求影响测试速度。

最小 demo

最简单的示例是使用 vi.fn()。这会得到一个默认什么都不做(仅返回 undefined)的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { expect, test, vi } from 'vitest'

test('mock function basics', () => {
const getApples = vi.fn()

// 调用它
getApples()

// 检查它是否被调用过
expect(getApples).toHaveBeenCalled()
expect(getApples).toHaveBeenCalledTimes(1)

// 默认情况下,模拟函数返回 undefined
expect(getApples()).toBeUndefined()
})

模拟返回值

如果需要模拟返回具体的值,可以使用 mockReturnValuemockReturnValueOnce,前者总是返回这个值,后者只返回一次,后续都是默认返回。

1
2
3
4
5
6
7
8
9
10
11
12
test('mock return values', () => {
const getApples = vi.fn()

// 总是返回这个值
getApples.mockReturnValue(10)
expect(getApples()).toBe(10)

// 仅返回此值一次,然后回退到默认值
getApples.mockReturnValueOnce(20)
expect(getApples()).toBe(20) // 20(一次性)
expect(getApples()).toBe(10) // 回到默认值
})

如果你模拟的函数是异步的,请使用 mockResolvedValuemockRejectedValue 来控制 Promise 的结果。

模拟实现(允许入参)

如果不是模拟固定返回值,而是根据入参去执行一些逻辑,那就要用到mockImplementation ,提供快捷方式。

1
2
3
4
5
6
7
8
test('mock with custom implementation', () => {
const add = vi.fn()
add.mockImplementation((a, b) => a + b)
// 下面是个快捷方式,等于上面两行
// const add = vi.fn((a, b) => a + b)

expect(add(2, 3)).toBe(5)
})

检查调用(调用历史)

模拟函数还能记住每一次调用,可以检查调用了多少次,入参出参是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 调用次数
expect(greet).toHaveBeenCalledTimes(2)

// 检查特定参数
expect(greet).toHaveBeenCalledWith('Alice')
expect(greet).toHaveBeenCalledWith('Bob', 'Charlie')

// 按位置检查特定调用的参数
expect(greet).toHaveBeenNthCalledWith(1, 'Alice')
expect(greet).toHaveBeenLastCalledWith('Bob', 'Charlie')

// 访问原始调用数据
expect(greet.mock.calls).toEqual([
['Alice'],
['Bob', 'Charlie'],
])

.mock 属性让你能完全访问调用历史。

这里还有很多细节,我就不复制粘贴了,可以直接参考 https://cn.vitest.dev/guide/learn/mock-functions.html#inspecting-calls

监听已有方法

同时,我们还能监听对象现有的方法,它不需要创建新的函数,也可以观察每次调用,需要用到vi.spyOn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const calculator = {
add(a, b) { return a + b }
}

test('spy on a method', () => {
const spy = vi.spyOn(calculator, 'add')

// 原始实现仍然工作
expect(calculator.add(1, 2)).toBe(3)

// 但我们可以观察调用
expect(spy).toHaveBeenCalledWith(1, 2)
expect(spy).toHaveBeenCalledTimes(1)
})

test('spy can override implementation', () => {
const spy = vi.spyOn(calculator, 'add')
spy.mockReturnValue(42) // 现在 add 方法总是返回 42

expect(calculator.add(1, 2)).toBe(42)
})

重置模拟

前面说到模拟函数会记录每一次调用,当然也可以重置。

  • mockClear() 清除记录的调用历史和返回值,但保留你设置的任何自定义实现
  • mockReset() 执行 mockClear 的所有操作,并且还会移除所有自定义实现,将模拟恢复到其默认状态
  • mockRestore() 专门用于通过 vi.spyOn 创建的 spy。它会恢复对象的原始方法,有效地撤销 spy。对于 vi.fn() 创建的模拟,其行为与 mockReset 相同

https://cn.vitest.dev/guide/learn/mock-functions.html#resetting-mocks

下面这是在每个测试后自动恢复所有模拟。

1
2
3
4
5
6
7
8
9
10
11
12
13
const calculator = {
add(a, b) { return a + b }
}

afterEach(() => {
vi.restoreAllMocks()
})

test('spy is restored after the test', () => {
const spy = vi.spyOn(calculator, 'add').mockReturnValue(42)
expect(calculator.add(1, 2)).toBe(42)
// afterEach 会将 calculator.add 恢复到原始实现
})

也可以直接全局配置

1
2
3
4
5
6
7
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
restoreMocks: true,
},
})

模拟模块

vi.mock 可以模拟、替换整个模块的导出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { getUser } from './db.js'

vi.mock(import('./db.js'), () => ({
getUser: vi.fn()
}))

// 这里的 import('./db.js') 用来指定要模拟的模块,
// 不是业务代码在运行时动态加载 db.js。
// Vitest 会在导入模块前执行 vi.mock,
// 并用工厂函数返回的对象替换 db.js 的导出。

test('mock a module', () => {
vi.mocked(getUser).mockReturnValue({ name: 'Alice' })

const user = getUser(1)
expect(user.name).toBe('Alice')
expect(getUser).toHaveBeenCalledWith(1)
})

做了什么?

  1. 指定要模拟的模块:这里指定的是 ./db.js
  2. 创建模拟导出:工厂函数返回 { getUser: vi.fn() }
  3. 替换真实导出:测试文件中的 getUser 会指向这个模拟函数。
  4. 在测试中设置行为:通过 vi.mocked(getUser).mockReturnValue(...) 指定它的返回值。

最后

学到这里,最小 demo 能跑通了,也能进行基础的单元测试,常用 API 也有了大概了解。

但 Vitest 只是测试工具,测试本身还包括测试用例设计。软考系统分析师的测试章节中提到过等价类划分、边界值分析、判定表、因果图、场景法和错误推测等方法。这些方法帮助我们判断应该测试哪些情况,比单纯记住几个 API 更接近测试思维。

因此,学习 Vitest 的目的不是转行去做测试,而是在开发时多考虑正常情况、异常情况和边界情况,尽量提前发现问题。

参考

1、快速起步 | 指南 | Vitest