模拟 (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', () => {
// 设置营业时间内的时段
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' });
});
});
函数
模拟函数可以分为两种不同的类别:侦察 (spying) 和模拟 (mocking)。
有时您只需要验证特定函数是否已被调用(以及可能传递了哪些参数)。在这些情况下,使用侦察 (spy) 就足够了,您可以直接使用 vi.spyOn()
(在此处阅读更多)。
然而,侦察只能帮助您侦察函数,它们无法更改这些函数的实现。在这种情况下,如果我们需要创建函数的模拟版本,我们可以使用 vi.fn()
(在此处阅读更多)。
我们使用 Tinyspy 作为模拟函数的基础,但我们有自己的封装器使其与 jest
兼容。vi.fn()
和 vi.spyOn()
都共享相同的方法,但只有 vi.fn()
的返回结果是可调用的。
示例
import { afterEach, describe, expect, it, vi } from 'vitest';
const messages = {
items: [
{ message: 'Simple test message', from: 'Testman' },
// ...
],
getLatest, // 如果支持,也可以是 `getter 或 setter` 方法
};
function getLatest(index = messages.items.length - 1) {
return messages.items[index];
}
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 将通过调用该模块并模拟其所有导出项来模拟模块本身。
以下原则适用:
- 所有数组都将被清空
- 所有原始类型和集合将保持不变
- 所有对象都将被深度克隆
- 类的所有实例及其原型都将被深度克隆
虚拟模块
Vitest 支持模拟 Vite 虚拟模块。它的工作方式与 Jest 中处理虚拟模块的方式不同。您无需将 virtual: true
传递给 vi.mock
函数,而是需要告知 Vite 该模块的存在,否则它将在解析期间失败。您可以通过以下几种方式实现:
- 提供别名
// vitest.config.js
export default {
test: {
alias: {
'$app/forms': resolve('./mocks/forms.js'),
},
},
};
- 提供一个解析虚拟模块的插件
// vitest.config.js
export default {
plugins: [
{
name: 'virtual-modules',
resolveId(id) {
if (id === '$app/forms') {
return 'virtual:$app/forms';
}
},
},
],
};
第二种方法的优点是您可以动态创建不同的虚拟入口点。如果您将多个虚拟模块重定向到单个文件,那么所有这些模块都将受到 vi.mock
的影响,因此请务必使用唯一的标识符。
模拟陷阱
请注意,无法模拟同一文件中其他方法内部调用的方法。例如,在以下代码中:
export function foo() {
return 'foo';
}
export function foobar() {
return `${foo()}bar`;
}
由于 foo
方法被直接引用,因此无法从外部对其进行模拟。因此,此代码对 foobar
内部的 foo
调用没有影响(但会影响其他模块中的 foo
调用):
import { vi } from 'vitest';
import * as mod from './foobar.js';
// 这只会影响原始模块外部的 "foo"
vi.spyOn(mod, 'foo');
vi.mock('./foobar.js', async importOriginal => {
return {
...(await importOriginal<typeof import('./foobar.js')>()),
// 这只会影响原始模块外部的 "foo"
foo: () => 'mocked',
};
});
您可以通过直接为 foobar
方法提供实现来确认此行为:
// foobar.test.js
import * as mod from './foobar.js';
vi.spyOn(mod, 'foo');
// 导出的 foo 引用模拟方法
mod.foobar(mod.foo);
// foobar.js
export function foo() {
return 'foo';
}
export function foobar(injectedFoo) {
return injectedFoo === foo; // false
}
这是预期行为。当模拟以这种方式介入时,通常表明代码设计不佳。请考虑将代码重构为多个文件,或通过使用依赖注入等技术来改进应用程序架构。
示例
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Client } from 'pg';
import { failure, success } from './handlers.js';
// 获取待办事项
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 不提供任何内置的文件系统模拟 API。您可以使用 vi.mock
手动模拟 fs
模块,但这很难维护。相反,我们建议使用 memfs
来实现此目的。memfs
创建一个内存文件系统,它模拟文件系统操作而无需触及实际磁盘。这种方法快速且安全,避免对真实文件系统产生任何潜在的副作用。
示例
要自动将每个 fs
调用重定向到 memfs
,您可以在项目根目录创建 __mocks__/fs.cjs
和 __mocks__/fs/promises.cjs
文件:
// 我们也可以使用 `import`,但这样
// 每个导出都应该明确定义
const { fs } = require('memfs');
module.exports = fs;
// 我们也可以使用 `import`,但这样
// 每个导出都应该明确定义
const { fs } = require('memfs');
module.exports = fs.promises;
// read-hello-world.js
import { readFileSync } from 'node:fs';
export function readHelloWorld(path) {
return readFileSync(path);
}
// hello-world.test.js
import { beforeEach, expect, it, vi } from 'vitest';
import { fs, vol } from 'memfs';
import { readHelloWorld } from './read-hello-world.js';
// 告诉 Vitest 使用 __mocks__ 文件夹中的 fs 模拟
// 如果 fs 应该始终被模拟,这可以在 setup 文件中完成
vi.mock('node:fs');
vi.mock('node:fs/promises');
beforeEach(() => {
// 重置内存文件系统的状态
vol.reset();
});
it('should return correct text', () => {
const path = '/hello-world.txt';
fs.writeFileSync(path, 'hello world');
const text = readHelloWorld(path);
expect(text).toBe('hello world');
});
it('can return a value multiple times', () => {
// 您可以使用 vol.fromJSON 定义多个文件
vol.fromJSON(
{
'./dir1/hw.txt': 'hello dir1',
'./dir2/hw.txt': 'hello dir2',
},
// 默认 cwd
'/tmp'
);
expect(readHelloWorld('/tmp/dir1/hw.txt')).toBe('hello dir1');
expect(readHelloWorld('/tmp/dir2/hw.txt')).toBe('hello dir2');
});
请求
由于 Vitest 在 Node 中运行,模拟网络请求比较复杂;Web API 不可用,因此我们需要一些工具来模拟网络行为。我们建议使用 Mock Service Worker 来实现此目的。它将允许您模拟 REST
和 GraphQL
网络请求,并且与框架无关。
Mock Service Worker (MSW) 通过拦截您的测试发出的请求来工作,允许您在不更改任何应用程序代码的情况下使用它。在浏览器中,这使用 Service Worker API。在 Node.js 中,对于 Vitest,它使用 @mswjs/interceptors
库。要了解有关 MSW 的更多信息,请阅读其介绍。
配置
您可以在设置文件中按如下方式使用它:
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { graphql, http, HttpResponse } from 'msw';
const posts = [
{
userId: 1,
id: 1,
title: 'first post title',
body: 'first post body',
},
// ...
];
export const restHandlers = [
http.get('https://rest-endpoint.example/path/to/posts', () => {
return HttpResponse.json(posts);
}),
];
const graphqlHandlers = [
graphql.query('ListPosts', () => {
return HttpResponse.json({
data: { posts },
});
}),
];
const server = setupServer(...restHandlers, ...graphqlHandlers);
// 在所有测试之前启动服务器
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 在所有测试之后关闭服务器
afterAll(() => server.close());
// 在每次测试后重置处理程序(对测试隔离很重要)
afterEach(() => server.resetHandlers());
使用
onUnhandleRequest: 'error'
配置服务器可确保当存在没有相应请求处理程序的请求时,会抛出错误。
更多
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 小时
}
function executeEveryMinute(func) {
setInterval(func, 1000 * 60); // 1 分钟
}
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);
// 推进 2ms 不会触发函数
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);
});
});
类
您可以通过单个 vi.fn
调用来模拟整个类——因为所有类也都是函数,所以这可以直接使用。请注意,目前 Vitest 不支持 new
关键字,因此函数体中的 new.target
始终是 undefined
。
class Dog {
name: string;
constructor(name: string) {
this.name = name;
}
static getType(): string {
return 'animal';
}
speak(): string {
return 'bark!';
}
isHungry() {}
feed() {}
}
我们可以使用 ES5 函数重新创建这个类:
const Dog = vi.fn(function (name) {
this.name = name;
});
// 注意静态方法是直接在函数上模拟的,
// 而不是在类的实例上
Dog.getType = vi.fn(() => 'mocked animal');
// 模拟类的每个实例上的 "speak" 和 "feed" 方法
// 所有 `new Dog()` 实例都将继承这些 spy
Dog.prototype.speak = vi.fn(() => 'loud bark!');
Dog.prototype.feed = vi.fn();
何时使用?
一般来说,如果类是从另一个模块重新导出的,您会在模块工厂中重新创建此类:
import { Dog } from './dog.js';
vi.mock(import('./dog.js'), () => {
const Dog = vi.fn();
Dog.prototype.feed = vi.fn();
// ... 其他模拟
return { Dog };
});
此方法还可以用于将类的实例传递给接受相同接口的函数:
// ./src/feed.ts
function feed(dog: Dog) {
// ...
}
// ./tests/dog.test.ts
import { expect, test, vi } from 'vitest';
import { feed } from '../src/feed.js';
const Dog = vi.fn();
Dog.prototype.feed = vi.fn();
test('can feed dogs', () => {
const dogMax = new Dog('Max');
feed(dogMax);
expect(dogMax.feed).toHaveBeenCalled();
expect(dogMax.isHungry()).toBe(false);
});
现在,当我们创建 Dog
类的新实例时,它的 speak
方法(以及 feed
)已经被模拟了:
const dog = new Dog('Cooper');
dog.speak(); // loud bark!
// 您可以使用内置断言来检查调用的有效性
expect(dog.speak).toHaveBeenCalled();
我们可以为特定实例重新分配返回值:
const dog = new Dog('Cooper');
// "vi.mocked" 是一个类型辅助工具,因为
// TypeScript 不知道 Dog 是一个被模拟的类,
// 它会将任何函数包装成 MockInstance<T> 类型
// 而不验证该函数是否为模拟函数
vi.mocked(dog.speak).mockReturnValue('woof woof');
dog.speak(); // woof woof
要模拟属性,我们可以使用 vi.spyOn(dog, 'name', 'get')
方法。这使得可以在模拟属性上使用 spy 断言:
const dog = new Dog('Cooper');
const nameSpy = vi.spyOn(dog, 'name', 'get').mockReturnValue('Max');
expect(dog.name).toBe('Max');
expect(nameSpy).toHaveBeenCalledTimes(1);
TIP
您也可以使用相同的方法侦察 getter 和 setter。
速查表 (Cheat Sheet)
INFO
以下示例中的 vi
直接从 vitest
导入。如果您的配置中将 globals
设置为 true
,您也可以全局使用。
我想…
模拟导出的变量
// 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');
模拟导出的函数
vi.mock
示例:
WARNING
不要忘记 vi.mock
调用会被提升到文件顶部。它将始终在所有导入之前执行。
// ./some-path.js
export function method() {}
import { method } from './some-path.js';
vi.mock('./some-path.js', () => ({
method: vi.fn(),
}));
vi.spyOn
示例:
import * as exports from './some-path.js';
vi.spyOn(exports, 'method').mockImplementation(() => {});
模拟导出的类实现
vi.mock
和.prototype
示例:
// ./some-path.ts
export class SomeClass {}
import { SomeClass } from './some-path.js';
vi.mock(import('./some-path.js'), () => {
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
return { SomeClass };
});
// SomeClass.mock.instances 将是 SomeClass 的实例
vi.spyOn
示例:
import * as mod from './some-path.js';
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
vi.spyOn(mod, 'SomeClass').mockImplementation(SomeClass);
侦察从函数返回的对象
- 使用缓存的示例:
// 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(import('./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();
模拟模块的一部分
import { mocked, original } from './some-path.js';
vi.mock(import('./some-path.js'), async importOriginal => {
const mod = await importOriginal();
return {
...mod,
mocked: vi.fn(),
};
});
original(); // 具有原始行为
mocked(); // 是一个侦察函数
WARNING
请注意,这只模拟_外部_访问。在此示例中,如果 original
在内部调用 mocked
,它将始终调用模块中定义的函数,而不是模拟工厂中的函数。
模拟当前日期
要模拟 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();
模拟全局变量
您可以通过将值分配给 globalThis
或使用 vi.stubGlobal
辅助函数来设置全局变量。使用 vi.stubGlobal
时,它不会在测试之间自动重置,除非您启用 unstubGlobals
配置选项或手动调用 vi.unstubAllGlobals
。
vi.stubGlobal('__VERSION__', '1.0.0');
expect(__VERSION__).toBe('1.0.0');
模拟 import.meta.env
- 要更改环境变量,您只需为其分配一个新值。
WARNING
环境变量值不会在测试之间自动重置。
import { beforeEach, expect, it } from 'vitest';
// 在运行测试之前,"VITE_ENV" 的值为 "test"
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');
});
- 如果您想自动重置值,可以使用
vi.stubEnv
辅助函数并启用unstubEnvs
配置选项(或在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('该值在运行另一个测试之前已恢复', () => {
expect(import.meta.env.VITE_ENV).toBe('test');
});
// vitest.config.ts
export default defineConfig({
test: {
unstubEnvs: true,
},
});