Trong test, bạn cần giả lập một dependency — nhưng chọn vi.mock hay vi.spyOn? Hai công cụ nghe giống nhau mà làm hai việc khác hẳn: vi.mock thay cả module, còn vi.spyOn chỉ bọc một method có sẵn và mặc định vẫn gọi code gốc.
Chọn sai thì trả giá: mock quá tay khiến test dễ vỡ, sửa một dòng là đỏ cả file; hoặc quên restore khiến mock rò rỉ sang test sau. Bài này phân biệt rạch ròi hai công cụ trên Vitest 4 và chỉ cho bạn khi nào dùng cái nào.
1. vi.fn(): mock function nền tảng
Trước khi so sánh vi.mock với vi.spyOn, cần hiểu nền tảng chung của cả hai: mock function. Về bản chất, một mock function chỉ là một hàm tự ghi lại nó được gọi bao nhiêu lần, với đối số gì:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Bản chất của một mock function: một hàm tự ghi lại cách nó được gọi
function createMock(impl = () => {}) {
const calls = [];
const mock = (...args) => {
calls.push(args);
return impl(...args);
};
mock.calls = calls;
return mock;
}
const sendEmail = createMock(() => "sent");
sendEmail("a@example.com", "Xin chào");
sendEmail("b@example.com", "Yo");
console.log("Số lần gọi:", sendEmail.calls.length);
console.log("Đối số lần gọi đầu:", sendEmail.calls[0].join(", "));Output:
1
2
Số lần gọi: 2
Đối số lần gọi đầu: a@example.com, Xin chàovi.fn() chính là bản đầy đủ của ý tưởng đó. Bạn định nghĩa hành vi trả về, rồi assert xem nó được gọi ra sao:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { describe, it, expect, vi } from 'vitest'
describe('vi.fn cơ bản', () => {
it('mockReturnValue và assert cách gọi', () => {
const getPrice = vi.fn()
getPrice.mockReturnValue(100)
expect(getPrice('apple')).toBe(100)
expect(getPrice).toHaveBeenCalledWith('apple')
expect(getPrice).toHaveBeenCalledTimes(1)
})
it('mockImplementation cho logic tuỳ biến', () => {
const discount = vi.fn().mockImplementation((price: number) => price * 0.9)
expect(discount(200)).toBe(180)
})
it('mockResolvedValue cho async', async () => {
const loadUser = vi.fn().mockResolvedValue({ id: 1, name: 'An' })
await expect(loadUser()).resolves.toEqual({ id: 1, name: 'An' })
})
})Giải thích:
mockReturnValue(v)— trả vềvmỗi lần gọi. Dùng cho giá trị cố định.mockImplementation(fn)— thay cả thân hàm, khi output phụ thuộc vào input.mockResolvedValue(v)— rút gọn củamockImplementation(() => Promise.resolve(v)), dành cho hàm async.toHaveBeenCalledWith(...)vàtoHaveBeenCalledTimes(n)— assert cách mock được gọi, thứ mà một hàm thường không cho bạn nhìn thấy.
Hai công cụ chính của bài — vi.mock và vi.spyOn — đều tạo ra những vi.fn như thế này, chỉ khác ở phạm vi chúng tác động. Ta xem lần lượt.
2. vi.mock(): thay cả một module
vi.mock(path) thay toàn bộ một module bằng bản mock. Không cần factory, Vitest tự biến mọi export thành vi.fn() rỗng:
1
2
3
4
5
6
7
8
9
10
11
12
13
import { describe, it, expect, vi } from 'vitest'
import { formatName } from './api'
import { greet } from './greeter' // greet() gọi formatName() bên trong
vi.mock('./api')
describe('vi.mock thay cả module', () => {
it('mọi export biến thành vi.fn tự động', () => {
vi.mocked(formatName).mockReturnValue('BOB')
expect(greet('whatever')).toBe('Hello, BOB!')
expect(formatName).toHaveBeenCalledWith('whatever')
})
})Sau vi.mock('./api'), mọi nơi import từ ./api — kể cả bên trong greet() — đều nhận bản mock. Đây là cách để cách ly một module I/O (network, database, file system) khỏi unit test.
Lưu ý: hoisting — cái bẫy lớn nhất của vi.mock
Vitest kéo vi.mock lên đầu file, chạy trước cả các câu import. Hệ quả: factory của vi.mock không được tham chiếu biến khai báo bên ngoài, vì lúc factory chạy, biến đó chưa tồn tại. Đoạn dưới trông hợp lý nhưng ném lỗi:
1
2
3
4
5
6
7
import { formatName } from './api'
const fake = vi.fn(() => 'OUTER') // biến ngoài
vi.mock('./api', () => ({
formatName: fake, // ❌ tham chiếu biến ngoài trong factory bị hoisted
}))Chạy thử, Vitest báo đúng nguyên nhân:
1
2
3
4
Error: [vitest] There was an error when mocking a module. If you are using
"vi.mock" factory, make sure there are no top level variables inside, since
this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock
Caused by: ReferenceError: Cannot access 'fake' before initializationCách xử lý là vi.hoisted() — khai báo biến trong một block cũng được hoisted, nên nó sẵn sàng đúng lúc factory cần:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { describe, it, expect, vi } from 'vitest'
import { formatName } from './api'
const mocks = vi.hoisted(() => ({
formatName: vi.fn(() => 'HOISTED'),
}))
vi.mock('./api', () => ({
formatName: mocks.formatName,
}))
describe('vi.hoisted', () => {
it('factory dùng được biến đã hoist', () => {
expect(formatName('x')).toBe('HOISTED')
expect(formatName).toBe(mocks.formatName)
})
})Nếu nhiều test file cùng mock một module theo một kiểu, tách bản mock ra file __mocks__/ cạnh module (ví dụ src/__mocks__/api.ts) rồi chỉ cần gọi vi.mock('./api') — Vitest tự nhặt bản trong __mocks__.
3. vi.spyOn(): theo dõi một method có sẵn
vi.spyOn(obj, 'method') không thay cả module. Nó bọc một method có sẵn của một object, và điểm mấu chốt: mặc định vẫn gọi implementation gốc, chỉ lặng lẽ ghi lại các lời gọi.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { describe, it, expect, vi, afterEach } from 'vitest'
import { logger } from './logger' // logger.info(msg) trả về `INFO: ${msg}`
describe('vi.spyOn giữ implementation gốc', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('mặc định vẫn gọi hàm thật, chỉ ghi lại lời gọi', () => {
const spy = vi.spyOn(logger, 'info')
const result = logger.info('server started')
expect(result).toBe('INFO: server started') // vẫn chạy code gốc
expect(spy).toHaveBeenCalledWith('server started')
})
it('thay hành vi khi cần bằng mockReturnValue', () => {
const spy = vi.spyOn(logger, 'info').mockReturnValue('MUTED')
expect(logger.info('noisy')).toBe('MUTED')
expect(spy).toHaveBeenCalledOnce()
})
})Giải thích:
- Test đầu: không đụng gì tới hành vi,
logger.infovẫn trả về'INFO: server started'. Spy chỉ để quan sát — hoàn hảo khi bạn muốn assert “hàm này có được gọi không” mà không đổi kết quả. - Test sau: nối
.mockReturnValue('MUTED')để tạm thay hành vi. Từ đây spy hành xử như mộtvi.fnbình thường.
Điểm cần nhớ: vì spyOn sửa trực tiếp object thật, bạn phải khôi phục sau test bằng spy.mockRestore() hoặc vi.restoreAllMocks() (gọi trong afterEach như trên). Quên bước này, method bị thay sẽ rò rỉ sang test sau.
4. vi.spyOn vs vi.mock: chọn cái nào
Đặt cạnh nhau, khác biệt gọn trong bốn dòng:
| Tiêu chí | vi.spyOn(obj, 'method') | vi.mock('./path') |
|---|---|---|
| Phạm vi | Một method của một object | Cả module (mọi export) |
| Mặc định | Gọi implementation gốc, chỉ quan sát | Thay hết bằng vi.fn() rỗng |
| Hoisting | Không (chạy tại chỗ, trong test) | Có — factory bị kéo lên đầu file |
| Khôi phục | mockRestore / restoreAllMocks | Tự hết hiệu lực theo file; vi.unmock nếu cần |
Quy tắc chọn nhanh:
View Mermaid diagram code
flowchart TD
Start{"Muốn giả lập cái gì?"}
Start -->|"Chỉ 1 method, giữ phần còn lại thật"| Spy["vi.spyOn(obj, 'method')"]
Start -->|"Cả module: I/O, network, DB"| Mock["vi.mock('./path')"]
Spy --> SpyNote["Nhớ restore sau test"]
Mock --> MockNote["Cẩn thận hoisting; partial mock nếu cần giữ vài export"]
style Spy fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Mock fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style SpyNote fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style MockNote fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98aNói ngắn: càng giữ được nhiều hành vi thật thì test càng gần với thực tế. spyOn giữ nhiều nhất, vi.mock toàn phần giữ ít nhất. Chỉ dùng cách mock mạnh hơn khi cách nhẹ hơn không đủ.
5. Partial mock: giữ lại phần thật với vi.importActual
Nhiều khi bạn chỉ muốn mock một export trong module mà giữ nguyên phần còn lại — ví dụ mock fetchUser (đụng network) nhưng để formatName (thuần logic) chạy thật. Đây là lúc dùng vi.mock với factory async, lấy module gốc qua importOriginal rồi spread đè lên:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { describe, it, expect, vi } from 'vitest'
import { formatName, fetchUser } from './api'
vi.mock('./api', async (importOriginal) => {
const actual = await importOriginal<typeof import('./api')>()
return {
...actual, // giữ mọi export gốc
fetchUser: vi.fn().mockResolvedValue({ id: 99, name: 'mocked' }), // đè 1 cái
}
})
describe('partial mock', () => {
it('override fetchUser nhưng giữ formatName thật', async () => {
expect(formatName(' an ')).toBe('AN') // export thật vẫn chạy
await expect(fetchUser(1)).resolves.toEqual({ id: 99, name: 'mocked' })
})
})importOriginal (tham số factory truyền vào) nạp module thật; { ...actual, fetchUser: ... } giữ mọi export rồi chỉ thay fetchUser. Bạn cũng có thể gọi await vi.importActual('./api') để đạt kết quả tương tự. Cách này tránh được cảnh mock cả module rồi phải tái tạo thủ công những export mình vẫn cần.
6. TypeScript: lấy đúng type mock với vi.mocked
Sau khi vi.mock('./api'), với TypeScript thì formatName vẫn mang type gốc (name: string) => string — không có .mockReturnValue. Gọi thẳng formatName.mockReturnValue(...) sẽ báo lỗi compile. vi.mocked() là một type helper: lúc runtime nó trả về chính hàm đó, nhưng nói với TypeScript “đây là một mock”:
1
2
3
4
5
6
7
8
9
10
11
12
import { describe, it, expect, vi } from 'vitest'
import { formatName } from './api'
import { greet } from './greeter'
vi.mock('./api')
describe('vi.mocked cho đúng type', () => {
it('truy cập được API của mock', () => {
vi.mocked(formatName).mockReturnValue('BOB')
expect(greet('x')).toBe('Hello, BOB!')
})
})Khi mock một object phức tạp mà chỉ muốn khai báo vài field, dùng option partial: vi.mocked(getUser, { partial: true }) cho phép mockReturnValue với object thiếu field, thay vì phải dựng đủ mọi property.
7. Dọn dẹp mock giữa các test
Nguồn gốc phổ biến nhất của test “chạy riêng thì xanh, chạy cả bộ thì đỏ” là mock từ test trước rò rỉ sang test sau. Vitest cho ba mức dọn, và phân biệt chúng là điểm hay bị nhầm:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { describe, it, expect, vi } from 'vitest'
describe('mockClear vs mockReset', () => {
it('mockClear: xoá call history, GIỮ implementation', () => {
const fn = vi.fn().mockReturnValue('real')
fn()
expect(fn.mock.calls.length).toBe(1)
fn.mockClear()
expect(fn.mock.calls.length).toBe(0) // history sạch
expect(fn()).toBe('real') // implementation vẫn còn
})
it('mockReset: xoá history VÀ implementation đã set', () => {
const fn = vi.fn().mockReturnValue('real')
fn()
fn.mockReset()
expect(fn.mock.calls.length).toBe(0) // history sạch
expect(fn()).toBeUndefined() // implementation bị gỡ
})
})
describe('mockRestore chỉ có ý nghĩa với spyOn', () => {
it('trả method về bản gốc', () => {
const obj = { size: () => 10 }
const spy = vi.spyOn(obj, 'size').mockReturnValue(999)
expect(obj.size()).toBe(999)
spy.mockRestore()
expect(obj.size()).toBe(10) // về nguyên bản
})
})Tóm tắt ba mức, từ nhẹ tới nặng:
mockClear()— chỉ xoá call history (.mock.calls,.mock.results). Giữ nguyên implementation. Bản áp cho tất cả:vi.clearAllMocks().mockReset()— làm mọi thứmockClearlàm, cộng thêm gỡ implementation đã set. Bản áp cho tất cả:vi.resetAllMocks().mockRestore()— chỉ có tác dụng thực sự với mock tạo bằngvi.spyOn: trả method về bản gốc. Bản áp cho tất cả:vi.restoreAllMocks().
Lưu ý: mockReset không phải luôn về undefined
mockReset đưa implementation về đúng cái bạn truyền vào lúc tạo. Với vi.fn() rỗng (rồi mới .mockReturnValue như trên), reset xong hàm trả về undefined. Nhưng với vi.fn(() => 'real') — có sẵn implementation lúc khởi tạo — thì reset lại đưa về chính () => 'real' đó, không phải undefined. Đây là chỗ dễ đoán sai; mình phát hiện ra khi chạy thử, không phải khi đọc docs.
Thay vì gọi tay trong từng file, khai báo một lần trong vitest.config.ts để áp cho cả suite:
1
2
3
4
5
6
7
8
9
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
clearMocks: true, // gọi vi.clearAllMocks() trước mỗi test
// mockReset: true, // hoặc reset cả implementation
// restoreMocks: true, // hoặc restore spy về gốc — bật cái này nếu xài spyOn nhiều
},
})Ba option này ánh xạ 1-1 với ba method trên và chạy trước mỗi test. Khuyến nghị thực dụng: bật restoreMocks: true nếu bạn dùng vi.spyOn nhiều — nó gộp cả clear lẫn restore, và bạn không bao giờ phải nhớ viết afterEach nữa.
Tổng lại: dùng vi.spyOn khi muốn giữ phần lớn hành vi thật và chỉ quan sát hoặc tạm thay một method — nhớ restore. Dùng vi.mock khi cần cách ly cả một module, và nhớ hoisting cùng vi.hoisted khi factory cần biến ngoài. Cần giữ vài export thì partial mock với importOriginal thay vì mock toàn phần rồi dựng lại thủ công.
Nếu bạn dùng Jest, jest.spyOn và jest.mock là hai bản tương đương gần như 1-1 — đổi tên vi thành jest là chạy. Bài này cố tình không đụng tới mock network (nên dùng MSW để chặn ở tầng request thay vì spy thẳng global.fetch) và fake timers — hai chủ đề đó xứng đáng có bài riêng. Còn ở tầng unit test thuần, phân biệt được spyOn với mock là bạn đã tránh được phần lớn test dễ vỡ rồi.