这一年多来我开始使用 vibe coding,也开始思考如何限制和界定 agent 的行为,从而保证代码质量。对我来说,自测是一个不错的办法。
我突然发现我以前都没学习过自测,索性简单学习下,看看能做什么,能做到什么程度。
本篇文章为个人学习笔记,也可以作为你简单快速了解 vitest 这个框架的文章。
初体验
快速起步 - Vitest 中文官方
官方有个最小实践,我就不全文复制了,简单来说就是,安装-引入-编写测试代码-运行测试。
安装
引入
package.json 中引入指令
title:package.json 1 2 3 4 5 { "scripts" : { "test" : "vitest" } }
编写测试代码
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) 会先执行并得到实际结果,然后传给 expect。expect 接收这个“实际值”,并返回一组断言方法;这里通过 .toBe(3) 判断实际值是否和期望值 3 相等。
执行
执行这条命令,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) 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 }) class User { constructor (name ) { this .name = name } } expect (new User ('Peter' )).toEqual ({ name : 'Peter' }) expect (new User ('Peter' )).not .toStrictEqual ({ name : 'Peter' }) })
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' ) })
初始化和清理
重复初始化
在测试时,经常需要一些初始化好的数据 ,但是一些测试函数会对测试数据进行增删改查 ,影响下一个测试函数的进行,所以在测试函数开始的前后,需要进行数据初始化和清理,这就引入了beforeEach 和 afterEach 。
顾名思义,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 itemsbeforeEach (() => { 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 ) })
一次性初始化
前面我们提到,这 2 个钩子每个测试函数运行之前之后都会执行一次。面对一些耗时或者只需要初始化、清理一次的动作,就显得不合适,这时就需要用到 beforeAll 和 afterAll 。
它们在整个文件运行期间只执行一次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let dbConnectionbeforeAll (() => { 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 ('每个测试都会执行这个' ); }); describe ('User API' , () => { let user beforeEach (() => { user = { id : 1 , name : 'John' } }) test ('should return user name' , () => { expect (user.name ).toBe ('John' ) }) }) describe ('Product API' , () => { let product 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 ) expect (getApples ()).toBeUndefined () })
模拟返回值
如果需要模拟返回具体的值,可以使用 mockReturnValue、mockReturnValueOnce,前者总是返回这个值,后者只返回一次,后续都是默认返回。
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 ) expect (getApples ()).toBe (10 ) })
如果你模拟的函数是异步的,请使用 mockResolvedValue 和 mockRejectedValue 来控制 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) 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 ) 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 ) })
也可以直接全局配置
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 () })) test ('mock a module' , () => { vi.mocked (getUser).mockReturnValue ({ name : 'Alice' }) const user = getUser (1 ) expect (user.name ).toBe ('Alice' ) expect (getUser).toHaveBeenCalledWith (1 ) })
做了什么?
指定要模拟的模块:这里指定的是 ./db.js。
创建模拟导出:工厂函数返回 { getUser: vi.fn() }。
替换真实导出:测试文件中的 getUser 会指向这个模拟函数。
在测试中设置行为:通过 vi.mocked(getUser).mockReturnValue(...) 指定它的返回值。
最后
学到这里,最小 demo 能跑通了,也能进行基础的单元测试,常用 API 也有了大概了解。
但 Vitest 只是测试工具,测试本身还包括测试用例设计。软考系统分析师的测试章节中提到过等价类划分、边界值分析、判定表、因果图、场景法和错误推测等方法。这些方法帮助我们判断应该测试哪些情况,比单纯记住几个 API 更接近测试思维。
因此,学习 Vitest 的目的不是转行去做测试,而是在开发时多考虑正常情况、异常情况和边界情况,尽量提前发现问题。
参考
1、快速起步 | 指南 | Vitest