模拟
在编写测试时,您迟早会需要为内部或外部服务创建一个“伪造”的版本。这通常称为模拟 (Mocking)。Vitest 通过其 vi
辅助函数提供了实用功能来帮助您。您可以从 vitest
导入它,或者如果启用了 global
配置,也可以全局访问它。
WARNING
请务必在每次测试运行前或后清除或恢复模拟,以撤销不同测试运行之间的模拟状态更改!有关更多信息,请参阅 mockReset
方法的文档。
如果您不熟悉 vi.fn
、vi.mock
或 vi.spyOn
方法,请先查看 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' });
});
});
函数
模拟函数可以分为两种不同的类别:监听 (Spy) 和_模拟 (Mock)_。
有时您只需要验证特定函数是否已被调用(以及可能传递了哪些参数)。在这种情况下,一个监听器就足够了,您可以直接使用 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 该模块存在,否则它将在解析期间失败。您可以通过以下几种方式实现:
- 提供别名
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
alias: {
'$app/forms': resolve('./mocks/forms.js'),
},
},
});
- 提供一个解析虚拟模块的插件
import { defineConfig } from 'vitest/config';
export default defineConfig({
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
方法提供实现来确认此行为:
import * as mod from './foobar.js';
vi.spyOn(mod, 'foo');
// 导出的 foo 引用了被模拟的方法
mod.foobar(mod.foo);
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;
import { readFileSync } from 'node:fs';
export function readHelloWorld(path) {
return readFileSync(path, 'utf-8');
}
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 来实现此目的。它允许您模拟 http
、WebSocket
和 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 { 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 server = setupServer(...restHandlers);
// 在所有测试运行前启动服务器
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 在所有测试运行后关闭服务器
afterAll(() => server.close());
// 每次测试后重置处理程序,以确保测试隔离
afterEach(() => server.resetHandlers());
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { graphql, HttpResponse } from 'msw';
const posts = [
{
userId: 1,
id: 1,
title: 'first post title',
body: 'first post body',
},
// ...
];
const graphqlHandlers = [
graphql.query('ListPosts', () => {
return HttpResponse.json({
data: { posts },
});
}),
];
const server = setupServer(...graphqlHandlers);
// 在所有测试运行前启动服务器
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 在所有测试运行后关闭服务器
afterAll(() => server.close());
// 每次测试后重置处理程序以实现测试隔离
afterEach(() => server.resetHandlers());
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { ws } from 'msw';
const chat = ws.link('wss://chat.example.com');
const wsHandlers = [
chat.addEventListener('connection', ({ client }) => {
client.addEventListener('message', event => {
console.log('Received message from client:', event.data);
// 将收到的消息回显给客户端
client.send(`Server received: ${event.data}`);
});
}),
];
const server = setupServer(...wsHandlers);
// 在所有测试运行前启动服务器
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 在所有测试运行后关闭服务器
afterAll(() => server.close());
// 每次测试后重置处理程序以实现测试隔离
afterEach(() => server.resetHandlers());
将服务器配置为
onUnhandledRequest: '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';
}
greet = (): string => {
return `Hi! My name is ${this.name}!`;
};
speak(): string {
return 'bark!';
}
isHungry() {}
feed() {}
}
我们可以用 ES5 函数重新创建这个类:
const Dog = vi.fn(function (name) {
this.name = name;
// 在构造函数中模拟实例方法,每个实例都将有自己的监听器
this.greet = vi.fn(() => `Hi! My name is ${this.name}!`);
});
// 注意静态方法直接在函数上模拟,
// 而不是在类的实例上
Dog.getType = vi.fn(() => 'mocked animal');
// 模拟类的每个实例上的 "speak" 和 "feed" 方法
// 所有 `new Dog()` 实例都将继承并共享这些监听器
Dog.prototype.speak = vi.fn(() => 'loud bark!');
Dog.prototype.feed = vi.fn();
WARNING
如果构造函数返回非基本类型值,则该值将成为 new 表达式的结果。在这种情况下,[[Prototype]]
可能无法正确绑定:
const CorrectDogClass = vi.fn(function (name) {
this.name = name;
});
const IncorrectDogClass = vi.fn(name => ({
name,
}));
const Marti = new CorrectDogClass('Marti');
const Newt = new IncorrectDogClass('Newt');
Marti instanceof CorrectDogClass; // ✅ true
Newt instanceof IncorrectDogClass; // ❌ false!
何时使用?
一般来说,如果类是从另一个模块重新导出的,您会在模块工厂中重新创建此类:
import { Dog } from './dog.js';
vi.mock(import('./dog.js'), () => {
const Dog = vi.fn();
Dog.prototype.feed = vi.fn();
// ... 其他模拟
return { Dog };
});
此方法还可以用于将类的实例传递给接受相同接口的函数:
function feed(dog: Dog) {
// ...
}
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
和 greet
)已被模拟:
const Cooper = new Dog('Cooper');
Cooper.speak(); // loud bark!
Cooper.greet(); // Hi! My name is Cooper!
// 您可以使用内置断言来检查调用的有效性
expect(Cooper.speak).toHaveBeenCalled();
expect(Cooper.greet).toHaveBeenCalled();
const Max = new Dog('Max');
// 分配给原型的方法在实例之间共享
expect(Max.speak).toHaveBeenCalled();
expect(Max.greet).not.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')
方法。这使得可以在模拟属性上使用监听器断言:
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。
速查表
INFO
以下示例中的 vi
直接从 vitest
导入。如果您的配置中将 globals
设置为 true
,您也可以全局使用它。
我想…
模拟导出的变量
export const getter = 'variable';
import * as exports from './example.js';
vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked');
模拟导出的函数
vi.mock
示例:
WARNING
请注意,vi.mock
调用会被提升到文件顶部。它将始终在所有导入之前执行。
export function method() {}
import { method } from './example.js';
vi.mock('./example.js', () => ({
method: vi.fn(),
}));
vi.spyOn
示例:
import * as exports from './example.js';
vi.spyOn(exports, 'method').mockImplementation(() => {});
模拟导出的类实现
vi.mock
和.prototype
示例:
export class SomeClass {}
import { SomeClass } from './example.js';
vi.mock(import('./example.js'), () => {
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
return { SomeClass };
});
// SomeClass.mock.instances 将是 SomeClass 的实例
vi.spyOn
示例:
import * as mod from './example.js';
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
vi.spyOn(mod, 'SomeClass').mockImplementation(SomeClass);
侦察从函数返回的对象
- 使用缓存的示例:
export function useObject() {
return { method: () => true };
}
import { useObject } from './example.js';
const obj = useObject();
obj.method();
import { useObject } from './example.js';
vi.mock(import('./example.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('the value is restored before running an other test', () => {
expect(import.meta.env.VITE_ENV).toBe('test');
});
export default defineConfig({
test: {
unstubEnvs: true,
},
});