对象模拟(Mocking)
在编写测试时,你可能会因为时间问题,需要创建内部或外部服务的“假”版本,这通常被称为对象模拟操作。Vitest 通过 vi 提供了一些实用的函数用于解决这个问题。你可以使用 import { vi } from 'vitest' 或者全局配置进行访问它 (当启用全局配置时)。
::: warning 警告
不要忘记在每次测试运行前后清除或恢复模拟对象,以撤消运行测试时模拟对象状态的更改!有关更多信息,请参阅 mockReset 文档。
:::
如果你想先深入了解, 可以先阅读 API 的 vi 部分,或者继续跟着文档深入了解一下这个对象模拟的世界。
日期
有些时候,你可能需要控制日期来确保测试时的一致性。Vitest 使用了 @sinonjs/fake-timers 库来操作定时器以及系统日期。可以在这里找到有关特定 API 的更多详细信息。
示例
import { afterEach, describe, expect, it, vi } from 'vitest'const businessHours = [9, 17]const purchase = () => {const currentHour = new Date().getHours()const [open, close] = businessHoursif (currentHour > open && currentHour < close) {return { message: 'Success' }}return { message: 'Error' }};describe('purchasing flow', () => {beforeEach(() => {// 告诉 vitest 我们使用模拟时间vi.useFakeTimers()});afterEach(() => {// 每次测试运行后恢复日期vi.useRealTimers()});it('allows purchases within business hours', () => {// 在营业时间内设定时间const date = new Date(2000, 1, 1, 13)vi.setSystemTime(date)// 访问 Date.now() 将导致上面设置的日期expect(purchase()).toEqual({ message: 'Success' })})it('disallows purchases outside of business hours', () => {// 设置营业时间以外的时间const date = new Date(2000, 1, 1, 19)vi.setSystemTime(date)// 访问 Date.now() 将导致上面设置的日期expect(purchase()).toEqual({ message: 'Error' })})})
函数
对于函数的模拟可以分为两种类别:对象监听(Spy) 和对象模拟.
有时你可能只需要验证是否调用了特定函数(以及可能传递了哪些参数),在这种情况下,我们就需要使用一个对象监听,可以直接使用 vi.spyOn() 来对一个函数进行监听(在这里可以阅读更多的内容)。
然而对象监听操作没有办法去更改这些函数的实现。当我们需要创建一个可以使用的假的(或模拟的)函数版本时,可以使用 vi.fn()(在这里可以阅读更多的内容)。
我们使用 Tinyspy 作为模拟函数的基础,同时也有一套自己的包装器来使其与 Jest 兼容。vi.fn() 和 vi.spyOn() 共享相同的方法,但是只有 vi.fn() 的返回结果是可调用的。
示例
import { afterEach, describe, expect, it, vi } from 'vitest'const getLatest = (index = messages.items.length - 1) => messages.items[index]const messages = {items: [{ message: 'Simple test message', from: 'Testman' },// ...],getLatest, // 也可以是“getter 或 setter(如果支持)”describe('reading messages', () => {afterEach(() => {vi.restoreAllMocks()})it('should get the latest message with a spy', () => {const spy = vi.spyOn(messages, 'getLatest')expect(spy.getMockName()).toEqual('getLatest')expect(messages.getLatest()).toEqual(messages.items[messages.items.length - 1]);expect(spy).toHaveBeenCalledTimes(1)spy.mockImplementationOnce(() => 'access-restricted')expect(messages.getLatest()).toEqual('access-restricted')expect(spy).toHaveBeenCalledTimes(2)});it('should get with a mock', () => {const mock = vi.fn().mockImplementation(getLatest)expect(mock()).toEqual(messages.items[messages.items.length - 1])expect(mock).toHaveBeenCalledTimes(1)mock.mockImplementationOnce(() => 'access-restricted')expect(mock()).toEqual('access-restricted')expect(mock).toHaveBeenCalledTimes(2)expect(mock()).toEqual(messages.items[messages.items.length - 1])expect(mock).toHaveBeenCalledTimes(3)})})
了解更多
模块
模拟模块观察第三方库,在一些其他代码中被调用,允许你测试参数、输出甚至重新声明其实现。
参见 vi.mock() API 部分,以获得更深入详细 API 描述。
自动模拟算法
如果你的代码导入了模拟模块,但是没有使用 __mocks__ 文件或 factory 模块,Vitest 将会通过调用和模拟模块的每个导出来模拟模块本身。
适用于以下这些原则:
- 所有的数组将被清空
- 所有的基础类型和集合将保持不变
- 所有的对象都将被深度克隆
- 类的所有实例及其原型都将被深度克隆
示例
import { vi, beforeEach, afterEach, describe, it } from 'vitest';import { Client } from 'pg';// handlersexport function success(data) {}export function failure(data) {}// get todosexport const getTodos = async (event, context) => {const client = new Client({// ...clientOptions});await client.connect()try {const result = await client.query(`SELECT * FROM todos;`)client.end()return success({message: `${result.rowCount} item(s) returned`,data: result.rows,status: true,})} catch (e) {console.error(e.stack)client.end()return failure({ message: e, status: false })}};vi.mock('pg', () => {return {Client: vi.fn(() => ({connect: vi.fn(),query: vi.fn(),end: vi.fn(),})),};});vi.mock('./handler.js', () => {return {success: vi.fn(),failure: vi.fn(),};});describe('get a list of todo items', () => {let client;beforeEach(() => {client = new Client()});afterEach(() => {vi.clearAllMocks()});it('should return items successfully', async () => {client.query.mockResolvedValueOnce({ rows: [], rowCount: 0 })await getTodos()expect(client.connect).toBeCalledTimes(1)expect(client.query).toBeCalledWith('SELECT * FROM todos;')expect(client.end).toBeCalledTimes(1)expect(success).toBeCalledWith({message: '0 item(s) returned',data: [],status: true,})})it('should throw an error', async () => {const mError = new Error('Unable to retrieve rows')client.query.mockRejectedValueOnce(mError)await getTodos()expect(client.connect).toBeCalledTimes(1)expect(client.query).toBeCalledWith('SELECT * FROM todos;')expect(client.end).toBeCalledTimes(1)expect(failure).toBeCalledWith({ message: mError, status: false })})})
网络请求
因为 Vitest 运行在 Node 环境中,所以模拟网络请求是一件非常棘手的事情; 由于没有办法使用 Web API,因此我们需要一些可以为我们模拟网络行为的包。推荐使用 Mock Service Worker 来进行这个操作。它可以同时模拟 REST 和 GraphQL 网络请求,并且跟所使用的框架没有任何联系。
Mock Service Worker (MSW) 通过拦截测试发出的请求进行工作,允许我们在不更改任何应用程序代码的情况下使用它。在浏览器中,会使用 Service Worker API。在 Node 和 Vitest 中,会使用 node-request-interceptor。要了解有关 MSW 的更多信息,可以去阅读他们的介绍。
配置
将以下内容添加到测试配置文件
import { beforeAll, afterAll, afterEach } from 'vitest'import { setupServer } from 'msw/node'import { graphql, rest } from 'msw'const posts = [{userId: 1,id: 1,title: 'first post title',body: 'first post body',},...]export const restHandlers = [rest.get('https://rest-endpoint.example/path/to/posts', (req, res, ctx) => {return res(ctx.status(200), ctx.json(posts))}),]const graphqlHandlers = [graphql.query('https://graphql-endpoint.example/api/v1/posts', (req, res, ctx) => {return res(ctx.data(posts))}),]const server = setupServer(...restHandlers, ...graphqlHandlers)// 在所有测试之前启动服务器beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))// 所有测试后关闭服务器afterAll(() => server.close())// 每次测试后重置处理程序“对测试隔离很重要”afterEach(() => server.resetHandlers())
配置服务
onUnhandleRequest: 'error'只要产生了没有相应类型的请求处理,就会发生错误。
示例
我们有一个使用 MSW 的完整工作示例: React Testing with MSW.
了解更多
MSW还有很多。您可以访问 cookie 和查询参数、定义模拟错误响应等等!要查看您可以使用 MSW 做什么,请阅读他们的文档。
定时器
每当我们测试涉及到超时或者间隔的代码时,并不是让我们的测试程序进行等待或者超时。我们也可以通过模拟对 setTimeout 和 setInterval 的调用来使用“假”定时器来加速我们的测试。
有关更深入的详细 API 描述,请参vi.mock() API 部分。
示例
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'const executeAfterTwoHours = (func) => {setTimeout(func, 1000 * 60 * 60 * 2) // 2 hours}const executeEveryMinute = (func) => {setInterval(func, 1000 * 60); // 1 minute}const mock = vi.fn(() => console.log('executed'));describe('delayed execution', () => {beforeEach(() => {vi.useFakeTimers()})afterEach(()=> {vi.restoreAllMocks()})it('should execute the function', () => {executeAfterTwoHours(mock);vi.runAllTimers();expect(mock).toHaveBeenCalledTimes(1);})it('should not execute the function', () => {executeAfterTwoHours(mock);// advancing by 2ms won't trigger the funcvi.advanceTimersByTime(2);expect(mock).not.toHaveBeenCalled();})it('should execute every minute', () => {executeEveryMinute(mock);vi.advanceTimersToNextTimer();vi.advanceTimersToNextTimer();expect(mock).toHaveBeenCalledTimes(1);vi.advanceTimersToNextTimer();expect(mock).toHaveBeenCalledTimes(2);})})
