Home / Testing / vi.spyOn vs vi.mock: Khi Nào Dùng Cái Nào Trong Vitest
Testing

vi.spyOn vs vi.mock: Khi Nào Dùng Cái Nào Trong Vitest

vi.spyOn hay vi.mock trong Vitest: khác nhau về phạm vi, hoisting, partial mock và cách dọn dẹp mock giữa test.

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ào

vi.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ề v mỗ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ủa mockImplementation(() => Promise.resolve(v)), dành cho hàm async.
  • toHaveBeenCalledWith(...)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.mockvi.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 initialization

Cá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.info vẫ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ột vi.fn bì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 viMột method của một objectCả module (mọi export)
Mặc địnhGọi implementation gốc, chỉ quan sátThay hết bằng vi.fn() rỗng
HoistingKhông (chạy tại chỗ, trong test)Có — factory bị kéo lên đầu file
Khôi phụcmockRestore / restoreAllMocksTự 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:#ffd98a

Nó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ứ mockClear là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ằng vi.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.spyOnjest.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.

← Previous
Flaky Test: Vì Sao Test Chập Chờn Và Cách Khắc Phục
Table of Contents
  1. 1. vi.fn(): mock function nền tảng
  2. 2. vi.mock(): thay cả một module
  3. 3. vi.spyOn(): theo dõi một method có sẵn
  4. 4. vi.spyOn vs vi.mock: chọn cái nào
  5. 5. Partial mock: giữ lại phần thật với vi.importActual
  6. 6. TypeScript: lấy đúng type mock với vi.mocked
  7. 7. Dọn dẹp mock giữa các test