模拟(Mocking)
在编写测试时,经常需要在测试过程中创建内部或外部服务的“模拟”版本。这通常被称为“模拟 (mocking)”。Vitest 提供了实用函数,可以通过 vi
辅助函数来帮助你。你可以 import { vi } from 'vitest'
或者全局访问它(当 全局配置 被启用时)。
WARNING
务必在每次测试运行前后清除或恢复模拟,以撤销不同运行之间的状态更改!有关更多信息,请参见 mockReset
文档。
如需快速了解,可以查看 API 部分;否则请继续阅读以更深入地探索模拟的世界。
日期
在某些情况下,需要控制日期以确保测试时的一致性。Vitest 使用 @sinonjs/fake-timers
包来操作定时器以及系统日期。详细 API 文档请参考此处。
示例
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const businessHours = [9, 17];
function purchase() {
const currentHour = new Date().getHours();
const [open, close] = businessHours;
if (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', () => {
// 设置在工作时间内的 hour
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', () => {
// 设置在工作时间外的 hour
const date = new Date(2000, 1, 1, 19);
vi.setSystemTime(date);
// 访问 Date.now() 将返回上面设置的日期
expect(purchase()).toEqual({ message: 'Error' });
});
});
函数
模拟函数可分为两类:间谍 (spying) 和模拟 (mocking)。
有时你只需要验证是否已调用特定函数(以及可能传递了哪些参数)。此时使用间谍 (spy) 即可,你可以直接使用 vi.spyOn()
(在此处阅读更多信息)。
但间谍仅能用于监视函数调用,无法更改这些函数的实现。如果我们需要创建一个函数的伪造(或模拟)版本,我们可以使用 vi.fn()
(在此处阅读更多信息)。
我们使用 Tinyspy 作为模拟函数的基础,并提供自己的包装器以兼容 jest
。vi.fn()
和 vi.spyOn()
共享相同的方法,但是只有 vi.fn()
的返回结果是可调用的。
示例
import { afterEach, describe, expect, it, vi } from 'vitest';
function getLatest(index = messages.items.length - 1) {
return messages.items[index];
}
const messages = {
items: [
{ message: 'Simple test message', from: 'Testman' },
// ...
],
getLatest, // can also be a `getter or setter if supported`
};
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.stubGlobal
辅助函数来模拟在 jsdom
或 node
中不存在的全局变量。它会将全局变量的值放入 globalThis
对象中。
import { vi } from 'vitest';
const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}));
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
// 现在你可以通过 `IntersectionObserver` 或 `window.IntersectionObserver` 访问它
模块
模拟模块允许你观察在某些其他代码中调用的第三方库,允许你测试参数、输出,甚至重新声明其实现。
有关更深入的详细 API 描述,请参见 vi.mock()
api 部分。
自动模拟算法
当代码导入被模拟的模块时,但没有相关的 __mocks__
文件或该模块的 factory
(工厂函数),Vitest 将通过调用它并模拟每个导出 (export) 来模拟模块本身。
以下原则适用:
- 所有数组将被清空
- 所有原始类型和集合将保持不变
- 所有对象将被深度克隆
- 所有类及其原型的实例将被深度克隆
示例
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Client } from 'pg';
import { failure, success } from './handlers.js';
// 处理成功/失败函数
export function success(data) {}
export function failure(data) {}
// 获取待办事项
export async function getTodos(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', () => {
const Client = vi.fn();
Client.prototype.connect = vi.fn();
Client.prototype.query = vi.fn();
Client.prototype.end = vi.fn();
return { Client };
});
vi.mock('./handlers.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 中运行,因此模拟网络请求非常棘手;浏览器 API 不可用,因此我们需要一些可以模拟网络行为的东西。我们推荐使用 Mock Service Worker 来完成此操作。它将允许你模拟 REST
和 GraphQL
网络请求,并且与框架无关。
Mock Service Worker (MSW) 通过拦截测试发出的请求来工作,允许你在不更改任何应用代码的情况下使用它。在浏览器中,这使用 Service Worker API。在 Node.js 中,对于 Vitest,它使用 node-request-interceptor。要了解有关 MSW 的更多信息,请阅读他们的 介绍。
配置
你可以在你的 setup file(设置文件)中像下面这样使用它:
import { afterAll, afterEach, beforeAll } 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.useFakeTimers
api 部分。
示例
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
function executeAfterTwoHours(func) {
setTimeout(func, 1000 * 60 * 60 * 2); // 2 hours
}
function 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);
// 提前 2 毫秒不会触发该函数
vi.advanceTimersByTime(2);
expect(mock).not.toHaveBeenCalled();
});
it('should execute every minute', () => {
executeEveryMinute(mock);
vi.advanceTimersToNextTimer();
expect(mock).toHaveBeenCalledTimes(1);
vi.advanceTimersToNextTimer();
expect(mock).toHaveBeenCalledTimes(2);
});
});
备忘单
INFO
以下示例中的 vi
直接从 vitest
导入。如果将 配置 中的 globals
设置为 true
,你也可以在全局使用它。
我想要…
- 监视 (Spy) 一个方法
method
const instance = new SomeClass();
vi.spyOn(instance, 'method');
- 模拟 (Mock) 导出的变量
// some-path.js
export const getter = 'variable';
// some-path.test.ts
import * as exports from './some-path.js';
vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked');
- 模拟 (Mock) 导出的函数
使用 vi.mock
的示例:
// ./some-path.js
export function method() {}
import { method } from './some-path.js';
vi.mock('./some-path.js', () => ({
method: vi.fn(),
}));
WARNING
请注意,vi.mock
的调用会被提升到文件的顶部。不要将 vi.mock
调用放在 beforeEach
中,因为只有其中一个会实际生效。
使用 vi.spyOn
的示例:
import * as exports from './some-path.js';
vi.spyOn(exports, 'method').mockImplementation(() => {});
- 模拟 (Mock) 导出的类的实现
使用 vi.mock
和原型(prototype)的示例:
// some-path.ts
export class SomeClass {}
import { SomeClass } from './some-path.js';
vi.mock('./some-path.js', () => {
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
return { SomeClass };
});
// SomeClass.mock.instances 将会包含 SomeClass 的实例
使用 vi.mock
和返回值(return value)的示例:
import { SomeClass } from './some-path.js';
vi.mock('./some-path.js', () => {
const SomeClass = vi.fn(() => ({
someMethod: vi.fn(),
}));
return { SomeClass };
});
// SomeClass.mock.results 将会包含返回的对象
使用 vi.spyOn
的示例:
import * as exports from './some-path.js';
vi.spyOn(exports, 'SomeClass').mockImplementation(() => {
// 可根据前两个示例中的任一方式实现
});
- 监视 (Spy) 从函数返回的对象
使用缓存的示例:
// some-path.ts
export function useObject() {
return { method: () => true };
}
// useObject.js
import { useObject } from './some-path.js';
const obj = useObject();
obj.method();
// useObject.test.js
import { useObject } from './some-path.js';
vi.mock('./some-path.js', () => {
let _cache;
const useObject = () => {
if (!_cache) {
_cache = {
method: vi.fn(),
};
}
// 现在每次调用 useObject() 时,它将
// 返回相同的对象引用
return _cache;
};
return { useObject };
});
const obj = useObject();
// obj.method 在 some-path 内部被调用
expect(obj.method).toHaveBeenCalled();
- 模拟 (Mock) 模块的一部分
import { mocked, original } from './some-path.js';
vi.mock('./some-path.js', async () => {
const mod = await vi.importActual<typeof import('./some-path.js')>(
'./some-path.js'
);
return {
...mod,
mocked: vi.fn(),
};
});
original(); // 具有原始行为
mocked(); // 是一个 spy 函数
- 模拟 (Mock) 当前日期
要模拟 Date
对象的时间,你可以使用 vi.setSystemTime
辅助函数。此值不会在不同的测试之间自动重置。
请注意,使用 vi.useFakeTimers
也会更改 Date
对象的时间。
const mockDate = new Date(2022, 0, 1);
vi.setSystemTime(mockDate);
const now = new Date();
expect(now.valueOf()).toBe(mockDate.valueOf());
// 重置模拟时间
vi.useRealTimers();
- 模拟 (Mock) 全局变量
你可以通过为 globalThis
赋值或使用 vi.stubGlobal
辅助函数来设置全局变量。当使用 vi.stubGlobal
时,除非你启用 unstubGlobals
配置选项或调用 vi.unstubAllGlobals
,否则它不会在不同的测试之间自动重置。
vi.stubGlobal('__VERSION__', '1.0.0');
expect(__VERSION__).toBe('1.0.0');
- 模拟 (Mock)
import.meta.env
要更改环境变量,你可以直接为其分配一个新值。此值不会在不同的测试之间自动重置。
import { beforeEach, expect, it } from 'vitest';
// 你可以在 `beforeEach` 钩子中手动重置它
const originalViteEnv = import.meta.env.VITE_ENV;
beforeEach(() => {
import.meta.env.VITE_ENV = originalViteEnv;
});
it('changes value', () => {
import.meta.env.VITE_ENV = 'staging';
expect(import.meta.env.VITE_ENV).toBe('staging');
});
如果你想自动重置该值,你可以使用启用了 unstubEnvs
配置选项的 vi.stubEnv
辅助函数(或者在 beforeEach
钩子中手动调用 vi.unstubAllEnvs
):
import { expect, it, vi } from 'vitest';
// 在运行测试之前,`VITE_ENV` 是 `test`
import.meta.env.VITE_ENV === 'test';
it('changes value', () => {
vi.stubEnv('VITE_ENV', 'staging');
expect(import.meta.env.VITE_ENV).toBe('staging');
});
it('the value is restored before running an other test', () => {
expect(import.meta.env.VITE_ENV).toBe('test');
});
// vitest.config.ts
export default {
test: {
unstubAllEnvs: true,
},
};