Có một giai đoạn mình viết test cho service gọi API thanh toán. Test xanh hết, CI pass, coverage 85%. Lên staging thì lỗi ngay dòng đầu: server trả data.items, còn code đọc data.result.
Vấn đề không nằm ở việc viết test, mà ở chỗ mock quá sâu. vi.mock('../lib/payment-client') thay nguyên một module, nên mình không còn test HTTP client, không test parse response, không test retry. Test chỉ còn xác nhận hàm A gọi hàm B — đúng kiểu test cho vui.
Mock tầng module và cái bẫy mang tên "giả định"
Khi mock ở tầng module, mình tự viết luôn hình dạng của dữ liệu trả về. Giả định đó không ai kiểm chứng. Server đổi field, đổi status code, thêm wrapper data — test vẫn xanh vì mock vẫn trả đúng cái mình tưởng.
MSW (Mock Service Worker) đi hướng khác: nó chặn ở tầng network. Trong Node, MSW thay http/fetch ở mức interceptor; trên trình duyệt, nó dùng Service Worker thật. Code gọi API vẫn chạy y như thật, chỉ có request là không đi ra internet.
Setup với Vitest
Cấu hình Vitest trỏ vào file setup chung:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./test/setup.ts'],
},
})
Khai báo handler — đây là "hợp đồng API" bằng code:
// test/msw/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('https://api.payment.dev/v1/charges/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
status: 'succeeded',
amount: 120_000,
})
}),
]
// test/msw/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// test/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './msw/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
onUnhandledRequest: 'error' là dòng đáng giá nhất trong file. Có nó, mọi request không khai báo handler sẽ làm test đỏ ngay — thay vì lặng lẽ đi ra internet rồi flaky lúc CI mạng chậm.
Override handler cho từng test
Không cần viết lại mock module. Chỉ cần đè handler ngay trong test đó:
import { describe, expect, it } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './msw/server'
import { getCharge, PaymentError } from '../src/payment'
describe('getCharge', () => {
it('đọc đúng field trong response', async () => {
const charge = await getCharge('ch_123')
expect(charge.status).toBe('succeeded')
})
it('ném PaymentError khi server trả 500', async () => {
server.use(
http.get('https://api.payment.dev/v1/charges/:id', () =>
HttpResponse.json({ message: 'boom' }, { status: 500 }),
),
)
await expect(getCharge('ch_123')).rejects.toThrowError(PaymentError)
})
})
server.resetHandlers() trong afterEach đảm bảo override của test này không rỉ sang test kế. Đây là lỗi hay gặp nhất khi mới dùng MSW: một test đè handler, test sau chạy một mình thì xanh, chạy cả file thì đỏ.
Một bộ handler, ba môi trường
Phần mình thích nhất: handlers.ts dùng chung cho cả ba chỗ.
- Vitest —
setupServerchạy trong Node. - Dev —
setupWorkerchạy trên trình duyệt, bật khi backend chưa xong. - Storybook / component test — cùng handler, không phải dựng mock riêng.
// src/mocks/browser.ts — chỉ dùng khi dev
import { setupWorker } from 'msw/browser'
import { handlers } from '../../test/msw/handlers'
export const worker = setupWorker(...handlers)
Backend đổi contract thì sửa một file handler, cả test lẫn dev server đều cập nhật theo. Đây là điểm vi.mock không bao giờ làm được, vì mock module nằm rải rác trong từng file test.
Vài bẫy khác
- Đừng assert implementation.
expect(fetch).toHaveBeenCalledTimes(1)buộc code phải gọi đúng một lần, trong khi thứ cần kiểm là kết quả cuối. Refactor là test đỏ oan. - Cẩn thận với thời gian. Handler trả dữ liệu có
expiresAttính theoDate.now()sẽ flaky. Dùngvi.setSystemTimevà pin một mốc cố định. - Đừng mock cả database bằng MSW. Nếu cần test câu SQL, index, transaction — dùng Postgres thật trong container, đừng giả lập ở tầng HTTP.
- Handler quá chi tiết cũng là gánh nặng. Chỉ khai báo endpoint mà code thật sự gọi, thêm path param thay vì hardcode từng URL.
Khi nào chưa cần MSW
Nếu chỉ có một hàm thuần tính toán, mock network là thừa. MSW đáng giá khi ranh giới cần test là HTTP: client, retry, timeout, map lỗi 4xx/5xx, parse response. Còn nếu cần tin vào hành vi của PostgreSQL hay Redis, mock không cứu được — phải chạy hệ thật.
Từ hồi chuyển sang MSW, số test của mình giảm gần một nửa mà độ tin tăng. Không phải vì MSW thần kỳ, mà vì nó buộc mình test đúng chỗ: hình dạng response thật, thay vì hình dạng mình tưởng tượng.
Bạn đang chặn network ở tầng nào trong dự án — mock module, MSW, hay chạy service thật luôn?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| MSW | Mock Service Worker — thư viện chặn request ở tầng network, dùng Service Worker trên trình duyệt và interceptor trên Node |
setupServer |
Hàm khởi tạo MSW cho môi trường Node, dùng trong Vitest/Jest |
| Handler | Định nghĩa cách trả lời một request (method + path → response) |
resetHandlers |
Xoá handler đè tạm thời, đưa về bộ handler gốc giữa các test |
onUnhandledRequest |
Chính sách khi gặp request không có handler: warn, error hoặc bypass |
| Flaky test | Test lúc xanh lúc đỏ dù code không đổi, thường do phụ thuộc mạng hoặc thời gian |