模擬(Mocking)
在撰寫測試時,您遲早會需要建立內部或外部服務的「虛擬」版本,這通常稱為 模擬(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' });
});
});
函數
模擬函數可以分為兩種類別:監控(spying)和模擬(mocking)。
有時您只需要驗證特定函數是否已被呼叫(以及可能傳遞了哪些參數)。在這些情況下,監控就足夠了,您可以直接使用 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 應該始終被模擬,這可以在設定檔中完成
vi.mock('node:fs');
vi.mock('node:fs/promises');
beforeEach(() => {
// 重置記憶體內 fs 的狀態
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);
// 提前 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);
});
});
類別
您可以透過單一 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
如果建構函式返回非原始值,則該值將成為新表達式的結果。在這種情況下,[[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
匯入。如果您在 config 中將 globals
設定為 true
,您也可以全域使用它。
我想要…
模擬匯出變數
export const getter = 'variable';
import * as exports from './example.js';
vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked');
模擬匯出函數
vi.mock
範例:
WARNING
別忘了 vi.mock
呼叫會被提升(hoisted)到檔案頂部。它將始終在所有匯入之前執行。
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"
import.meta.env.VITE_ENV === 'test';
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,
},
});