# Test tích hợp API của khách bằng Vitest và MSW mà không gọi hệ thống thật

> Staging của khách sập hoặc chưa có sandbox thì bạn vẫn test được 200, 401, 404 và 500 ngay trên laptop, nhờ một mock server chặn request ở tầng mạng.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-vitest-msw-test-api-khach-hang-typescript/

Thử hình dung thế này: chiều thứ Sáu, bạn đang gấp rút hoàn thành đoạn tích hợp với CRM của khách thì staging của họ sập, mà người duy nhất có quyền khởi động lại thì đang nghỉ phép. Nếu bộ test của bạn phải gọi vào hệ thống thật, cả tuần đó bạn coi như làm việc không có test.

Forward Deployed Engineer nào cũng có lúc gặp cảnh này, vì code của bạn phụ thuộc vào những API bạn không kiểm soát. Hướng dẫn này đưa ra một cách khác: test client thật bằng Vitest, còn vai API của khách giao cho MSW.

Lý do chọn MSW khá đơn giản: chính tài liệu của Vitest khuyên dùng Mock Service Worker để mock network request. MSW chặn được HTTP, GraphQL, SSE và WebSocket, và phần tích hợp Node của nó không gắn với Vitest, nên học xong bạn mang sang dự án khác vẫn dùng được.

## Bạn sẽ dựng gì, và cần chuẩn bị gì?

Bạn sẽ có một hàm `getCustomer(id, token)` gọi tới một API CRM giả định tại `crm.example.com`, kèm bốn test: thành công, thiếu token, không tìm thấy khách và server lỗi. Không test nào chạm vào mạng thật.

Bạn cần Node.js, một dự án TypeScript và hai gói dev dependency là `vitest` và `msw`. Lệnh dưới đây là bản ngắn gọn nhất; nếu dự án đòi phiên bản cụ thể, hãy xem tài liệu của từng thư viện.

```bash
npm install -D vitest msw
```

## Bước 1: viết client như sẽ chạy thật

Thứ bạn cần test là code sẽ chạy ở chỗ khách, nên hãy viết nó trước và không chừa sẵn chỗ nào cho việc mock.

```ts
// src/crm.ts
export async function getCustomer(id: string, token: string) {
  const res = await fetch(`https://crm.example.com/api/customers/${id}`, {
    headers: { authorization: `Bearer ${token}` },
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`CRM lỗi ${res.status}`)
  return res.json()
}
```

Cần kiểm tra: file này không import gì từ MSW. Client không hề biết mình đang bị test, vì MSW chủ trương API mocking nên có một tầng riêng, và khi mock nằm ở tầng mạng thì code ứng dụng giữ nguyên.

## Bước 2: handler cho happy path

Trong MSW, request handler là thứ chặn request và trả về response. Bạn viết nó bằng các helper `http.*`, còn resolver nhận các thuộc tính như `request`, `params` và `cookies`.

```ts
// src/mocks/handlers.ts
import { http } from 'msw'

export const handlers = [
  http.get('https://crm.example.com/api/customers/:id', ({ request, params }) => {
    if (!request.headers.get('authorization')) {
      return new Response(null, { status: 401 })
    }
    return Response.json({ id: params.id, name: 'Công ty A', tier: 'gold' })
  }),
]
```

Handler trả về một `Response` chuẩn của Fetch API, nên client nhận đúng kiểu đối tượng nó vẫn nhận khi chạy thật. Đoạn kiểm tra header `authorization` là một mô phỏng giản lược: handler cư xử như server thật và từ chối request thiếu token.

Kent C. Dodds cũng khuyên đi theo hướng này, tức là dựng một mock server xử lý request như server thật thay vì mock hàm fetch.

## Bước 3: server, file setup và chế độ chặn chặt

Trong Node, bạn import `setupServer` từ `msw/node` rồi truyền handler vào.

```ts
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
```

Tiếp theo là vòng đời: bật server trước khi chạy test, reset handler giữa các test và đóng server khi chạy xong. `server.listen()` là API đồng bộ nên không cần `await`.

```ts
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
```

```ts
// vitest.config.ts (giản lược)
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { setupFiles: ['./vitest.setup.ts'] } })
```

Dòng quan trọng nhất là `onUnhandledRequest: 'error'`, đúng cấu hình tài liệu Vitest đưa ra. Gặp endpoint nào không có handler, MSW sẽ báo lỗi thay vì để request lặng lẽ đi tới hệ thống thật của khách. Với FDE, đây là hàng rào để không ai lỡ ghi dữ liệu rác vào production của khách chỉ vì chạy `npm test`.

## Bước 4: ghi đè từng trường hợp lỗi

`server.use()` thêm handler vào đầu danh sách lúc runtime. Bạn dùng nó để thay response happy path trong một test cụ thể. Handler ghi đè tồn tại cho đến khi bị reset, vì vậy `resetHandlers()` mới nằm trong `afterEach`.

```ts
// src/crm.test.ts
import { describe, it, expect } from 'vitest'
import { http } from 'msw'
import { server } from './mocks/server'
import { getCustomer } from './crm'

const url = 'https://crm.example.com/api/customers/:id'

describe('getCustomer', () => {
  it('trả về khách khi có token', async () => {
    const c = await getCustomer('42', 'tok')
    expect(c.name).toBe('Công ty A')
  })

  it('báo lỗi khi thiếu token', async () => {
    await expect(getCustomer('42', '')).rejects.toThrow('401')
  })

  it('trả null khi không tìm thấy', async () => {
    server.use(http.get(url, () => new Response(null, { status: 404 })))
    expect(await getCustomer('999', 'tok')).toBeNull()
  })

  it('báo lỗi khi CRM trả 500', async () => {
    server.use(http.get(url, () => new Response(null, { status: 500 })))
    await expect(getCustomer('42', 'tok')).rejects.toThrow('500')
  })
})
```

Cần kiểm tra: chạy `npx vitest run`, bạn sẽ thấy ba test pass và một test fail, là test "báo lỗi khi thiếu token". Lý do: khi token rỗng, client vẫn gửi header `authorization` mang giá trị `Bearer`, nên handler coi như có token, trả 200 và hàm không ném lỗi.

Test fail ở đây là tin tốt. Bug nằm thật trong client ở Bước 1, và nếu không có test này nó đã theo code lên môi trường của khách. Cách sửa là chỉ gửi header khi thực sự có token:

```ts
// src/crm.ts (đã sửa)
export async function getCustomer(id: string, token: string) {
  const headers: Record<string, string> = {}
  if (token) headers.authorization = `Bearer ${token}`

  const res = await fetch(`https://crm.example.com/api/customers/${id}`, { headers })
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`CRM lỗi ${res.status}`)
  return res.json()
}
```

Chạy lại `npx vitest run`: lần này cả bốn test phải pass. Request thiếu token không còn header, handler trả 401, và client ném lỗi `CRM lỗi 401` đúng như test chờ đợi.

## Bước 5: cố ý làm hỏng để chắc bộ test biết bắt lỗi

Thử sửa `customers` thành `customer` trong URL của client rồi chạy lại. Mọi test đều phải fail, vì request không khớp handler nào và `onUnhandledRequest: 'error'` sẽ báo lỗi.

Kent C. Dodds giải thích vì sao bước này quan trọng: khi mock ở tầng mạng, gọi fetch sai cách thì handler không bao giờ được gọi và test fail, đúng như mong muốn. Một bản mock `fetch` viết tay trả về dữ liệu dựng sẵn bất kể bạn gọi URL nào, nên lỗi gõ nhầm đó sẽ lọt qua.

**Điểm mấu chốt:** Mock ở tầng mạng để test bắt được cả lỗi trong chính request bạn gửi đi.

## Những lỗi hay gặp

Lỗi đầu tiên là quên `resetHandlers()`. Handler 500 của test trước sẽ ở lại và làm test kế tiếp fail khó đoán, tùy thứ tự chạy. Nếu test pass khi chạy riêng nhưng fail khi chạy cả bộ, hãy nghi chỗ này trước.

Lỗi thứ hai là hạ `onUnhandledRequest` xuống mức nhẹ hơn cho "đỡ phiền". Cái giá là một request đi thẳng tới API thật của khách mà không ai hay. Lỗi thứ ba là nhồi quá nhiều logic vào handler mặc định: hãy để nó là happy path gọn gàng, còn trường hợp đặc biệt thì đưa vào `server.use()` trong từng test.

## Kỹ năng này xuất hiện thế nào ở chỗ khách?

Thử hình dung tuần đầu của một engagement: khách đưa bạn tài liệu API và vài response mẫu, nhưng chưa cấp quyền vào staging. Ngay hôm đầu, bạn có thể chuyển các response mẫu đó thành handler và viết client có test cho cả 401, 404, 500, trước khi được gọi thật.

Khi có quyền truy cập, việc đối chiếu chỉ còn là so response thật với handler. Chỗ nào lệch là chỗ cần hỏi lại khách, và bạn hỏi bằng một test cụ thể chứ không bằng cảm giác.

Nếu đang nhắm tới vai trò FDE, hãy để repo portfolio chứng minh bạn tích hợp được với hệ thống mình không kiểm soát: một client gọi API công khai, kèm bộ test MSW bật `onUnhandledRequest: 'error'` và một test cho mỗi mã lỗi. Trong CV, hãy ghi rằng bạn đã kiểm thử tích hợp khi chưa có sandbox, thay vì chỉ liệt kê "Vitest" trong mục kỹ năng.

Lần tới staging của khách sập vào chiều thứ Sáu, bộ test của bạn vẫn chạy như thường.

**Thử ngay tuần này:**

- Chọn một endpoint của khách mà code bạn đang gọi, viết một handler MSW cho happy path và một test lỗi 500 bằng server.use().
- Bật onUnhandledRequest: 'error' trong file setup của Vitest rồi chạy lại toàn bộ test để xem còn request nào đang đi ra ngoài.
- Cố ý gõ sai một ký tự trong URL của client và xác nhận test fail. Nếu test vẫn pass, mock của bạn đang nằm sai tầng.

## Nguồn

- [Quick start – Mock Service Worker](https://mswjs.io/docs/quick-start)

- [Node.js integration – Mock Service Worker](https://mswjs.io/docs/integrations/node)

- [Mocking Requests | Guide | Vitest](https://vitest.dev/guide/mocking/requests)

- [server.use() – Mock Service Worker](https://mswjs.io/docs/api/setup-server/use)

- [Intercepting requests – Mock Service Worker](https://mswjs.io/docs/http/intercepting-requests)

- [Philosophy – Mock Service Worker](https://mswjs.io/docs/philosophy)

- [Stop mocking fetch – Kent C. Dodds](https://kentcdodds.com/blog/stop-mocking-fetch)
