FDE PulseViệc làm FDE đang mở 641Mới trong 7 ngày 50Công ty đang tuyển 97Nhận làm từ xa 25%Lương trung vị (Mỹ) $204kTuyển nhiều nhất Databricks 122
EN

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

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.

Kỹ sư phần mềm ngồi trước laptop đang hiển thị mã nguồn, làm việc tại bàn trong văn phòng
Ảnh: Emile Perron / Unsplash
Chia sẻLinkedInFacebookX

Tóm tắt nhanh

  • MSW chặn request ở tầng mạng và trả về Request/Response chuẩn của Fetch API, nên code client không phải sửa gì.
  • Bật onUnhandledRequest: 'error' để mọi request không có handler đều làm test fail, tránh vô tình gọi vào hệ thống thật của khách.
  • Đặt sẵn happy path trong handler mặc định, dùng server.use() trong từng test để ép ra lỗi, và resetHandlers() sau mỗi test.
Sơ đồ: test Vitest gọi hàm getCustomer(), request đi qua MSW ở tầng mạng. Request khớp handler nhận response 200, 401, 404 hoặc 500; request không khớp bị báo lỗi và không tới CRM thật của khách.
Với onUnhandledRequest: 'error', mọi request không có handler đều bị báo lỗi thay vì lặng lẽ đi tới hệ thống thật của khách.

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.

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.

// 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.

// 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.

// 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.

// 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())
// 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.

// 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:

// 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.

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.

Bài này có hữu ích không?

Dùng cùng trợ lý AIHỏi Claude ↗Hỏi ChatGPT ↗
7 nguồn
Đọc tiếp trên lộ trình · Chặng 1: Nền tảngType hint và pyright: đọc nhanh codebase của khách, bắt lỗi None trước khi chạyBạn chưa sửa dòng nào trong repo của khách, vậy mà pyright đã có thể cho biết hàm nào sẽ trả về None khi dữ liệu thật thiếu một giá trị.