- 1. Mocking Requests คืออะไร
- 2. จุดสำคัญเรื่องเวอร์ชัน: MSW 2 และ MSW 3
- 3. เตรียมเครื่องและโปรเจกต์
- 4. ตั้งค่า Vitest
- 5. สร้าง API client ที่จะทดสอบ
- 6. กำหนดพฤติกรรม API ด้วย Handlers
- 7. เชื่อม MSW เข้ากับวงจรการทดสอบ
- 8. เขียน Test ครอบคลุมสถานการณ์หลัก
- 9. server.use() เปลี่ยนเฉพาะกรณีที่ต้องการทดสอบ
- 10. HTTP Error, Network Error และ Timeout ต่างกันอย่างไร
- 11. เปรียบเทียบกับการ Stub fetch() โดยตรง
- 12. รันและอ่านผลการทดสอบ
- 13. ใช้กับ React, Vue และ Browser Mode
- 14. ปัญหาที่พบบ่อย
- 15. แบบฝึกหัดสำหรับอบรม
- เอกสารอ้างอิง

บทความและ Workshop ภาษาไทย สำหรับผู้เริ่มต้นที่ใช้ JavaScript และ
async/awaitได้แล้ว
การทดสอบโค้ดที่เรียก API ควรตรวจสอบได้ทั้งวันที่เซิร์ฟเวอร์ทำงานปกติ วันที่ข้อมูลไม่ถูกต้อง และวันที่เครือข่ายมีปัญหา การรอให้ระบบจริงเกิดเหตุการณ์เหล่านี้ทำให้ทดสอบซ้ำได้ยาก เราจึงจำลองการตอบกลับ แล้วตรวจสอบว่าโปรแกรมจัดการแต่ละสถานการณ์ได้ตามที่ออกแบบไว้
บทความนี้สร้าง API client สำหรับระบบรายการงาน ใช้ Vitest รันการทดสอบ และใช้ Mock Service Worker (MSW) ดัก Requests เพื่อส่ง Response ที่กำหนด โดยไม่ต้องเปิด Backend จริง เอกสาร Vitest แนะนำ MSW สำหรับงานลักษณะนี้ [1]
#1. Mocking Requests คืออะไร
Mocking Requests คือการควบคุมผลลัพธ์ของการสื่อสารกับ API ในสภาพแวดล้อมทดสอบ เช่น ให้ GET /tasks/1 ส่งข้อมูลสำเร็จ หรือส่งสถานะ 500 เพื่อทดสอบทางออกเมื่อระบบมีปัญหา
ตัวโปรแกรมยังเรียก fetch() ตามปกติ แต่ MSW จับคู่ Request กับ Handler ที่เรากำหนด แล้วสร้าง Response กลับมาให้ API client จึงยังผ่านขั้นตอนสร้าง URL, serialize JSON, อ่าน Response และจัดการ Error จริงของฝั่ง client
ใน Node.js MSW ใช้กลไก Interceptors ส่วนในเว็บเบราว์เซอร์ใช้ Service Worker ดังนั้น setupServer() ใน Workshop นี้ไม่ได้เปิด HTTP server หรือจับจองพอร์ต และไม่ต้องสร้างไฟล์ Service Worker [1][2]
#เลือกวิธีจำลองให้เหมาะกับสิ่งที่ทดสอบ
| วิธี | สิ่งที่ควบคุม | ตัวอย่างการใช้งาน |
|---|---|---|
| MSW | พฤติกรรมของ HTTP endpoint | ทดสอบ API client และการเชื่อมต่อกับ Component |
vi.stubGlobal('fetch', ...) |
ฟังก์ชัน fetch โดยตรง |
Unit test ที่มีขอบเขตเล็กและต้องการควบคุม dependency |
vi.mock() |
โมดูลที่ import | จำลอง API wrapper เพื่อทดสอบ business logic ชั้นบน |
การเลือกใช้เป็นเรื่องขอบเขตของ Test: ถ้าต้องการตรวจว่า API client ประมวลผล Response ได้จริง การใช้ MSW จะครอบคลุมเส้นทางนั้นได้ดี ส่วนการ mock API wrapper เหมาะเมื่อพฤติกรรมภายใน wrapper ไม่ใช่สิ่งที่กำลังตรวจสอบ
#2. จุดสำคัญเรื่องเวอร์ชัน: MSW 2 และ MSW 3
Workshop นี้ใช้ MSW 3.0.3 จึงใช้ entrypoint ใหม่ ตรวจสอบ major version ของโปรเจกต์ก่อนคัดลอกตัวอย่างจากบทความอื่น [3]
| รายการ | MSW 2 | MSW 3 ที่ใช้ในบทความนี้ |
|---|---|---|
http, HttpResponse |
from 'msw' |
from 'msw/http' |
delay |
from 'msw' |
from 'msw/utils/delay' |
| Node integration | from 'msw/node' |
from 'msw/node' |
| ตั้งค่ากรณีไม่มี Handler | onUnhandledRequest |
onUnhandledFrame |
บางตัวอย่างในหน้า Vitest ยังแสดงรูปแบบ MSW 2 ขณะที่เอกสาร MSW 3 ระบุการเปลี่ยนแปลงเหล่านี้โดยตรง บทความนี้ยึดเวอร์ชันที่ระบุใน package.json และใช้รูปแบบเดียวกันตลอด [1][3]
#3. เตรียมเครื่องและโปรเจกต์
ใช้ Node.js 24.x เพื่อทำ Workshop ตามตัวอย่าง หรือใช้ Node.js รุ่นที่อยู่ในช่วงรองรับของแพ็กเกจที่กำหนด ชุดนี้กำหนด engine เป็น ^22.12.0 || ^24.0.0 || >=26.0.0 เอกสาร Vitest ระบุ Node.js ขั้นต่ำ 22.12.0 [4]
ตรวจสอบเครื่อง:
node --version
npm --version
#ทางเลือก A: ใช้ไฟล์ Workshop ที่แนบ
แตกไฟล์ ZIP แล้วเปิด Terminal ในโฟลเดอร์ vitest-requests-workshop:
npm ci
npm test
ไฟล์ ZIP มี package-lock.json เพื่อกำหนด dependency tree ที่ใช้ติดตั้งซ้ำ ไม่รวม node_modules จึงต้องเข้าถึง npm registry ในขั้นติดตั้ง หลังติดตั้งแล้ว Test ใช้ข้อมูลจำลองภายใน process
#ทางเลือก B: สร้างเองทีละขั้น
mkdir vitest-requests-workshop
cd vitest-requests-workshop
npm init -y
npm pkg set type=module
npm install -D --save-exact vitest@5.0.5 msw@3.0.3
npm pkg set scripts.test="vitest run"
npm pkg set scripts.test:watch="vitest"
สร้างไฟล์ตามตารางและโค้ดในหัวข้อถัดไป:
| ไฟล์ | หน้าที่ |
|---|---|
vitest.config.js |
กำหนด Environment และ setup file |
src/tasks-api.js |
API client ที่จะทดสอบ |
tests/mocks/handlers.js |
กำหนดพฤติกรรม endpoint |
tests/mocks/server.js |
สร้าง MSW server instance |
tests/setup.js |
เริ่ม Mock, ล้าง Handler และคืนสภาพ |
tests/tasks-api.test.js |
ทดสอบด้วย MSW |
tests/fetch-stub.test.js |
เปรียบเทียบการ Stub fetch โดยตรง |
#4. ตั้งค่า Vitest
ไฟล์ vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./tests/setup.js'],
include: ['tests/**/*.test.js'],
},
});
environment: 'node' เหมาะกับ API client ที่ไม่ใช้ DOM ส่วน setupFiles ทำให้ Vitest โหลด lifecycle ของ MSW ก่อนรัน Test ในแต่ละ test file
Workshop นี้ import test, expect และ hooks จาก vitest โดยตรง จึงไม่ต้องเปิด globals
#5. สร้าง API client ที่จะทดสอบ
กำหนดฐาน URL เป็น https://api.example.test ซึ่งใช้เป็นชื่อปลายทางของ API สมมติ Handler จะดัก Requests เหล่านี้ไว้ ไม่ต้องมี DNS หรือ Backend ของโดเมนนี้
ไฟล์ src/tasks-api.js
export const API_URL = 'https://api.example.test';
export class HttpError extends Error {
constructor(status, payload) {
super(payload?.message ?? `HTTP ${status}`);
this.name = 'HttpError';
this.status = status;
this.payload = payload;
}
}
async function requestJson(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, options);
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new HttpError(response.status, payload);
}
if (response.status === 204) return undefined;
return response.json();
}
export function getTask(id, options = {}) {
return requestJson(`/tasks/${encodeURIComponent(id)}`, options);
}
export function listTasks(status) {
const query = new URLSearchParams();
if (status) query.set('status', status);
const suffix = query.size ? `?${query}` : '';
return requestJson(`/tasks${suffix}`);
}
export function createTask(title, token) {
return requestJson('/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title }),
});
}
export function deleteTask(id) {
return requestJson(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
// A small teaching example: retry once, only on HTTP 503.
export async function getTaskWithRetry(id) {
try {
return await getTask(id);
} catch (error) {
if (error instanceof HttpError && error.status === 503) {
return getTask(id);
}
throw error;
}
}
#ทำไมต้องตรวจ response.ok
สำหรับ Fetch API การได้รับ HTTP 404 หรือ 500 ยังถือว่าได้รับ Response แล้ว จึงต้องตรวจ response.ok และแปลงเป็น Error ตามนโยบายของโปรแกรม ตัวอย่างนี้สร้าง HttpError ที่มี status และ payload ให้ผู้เรียกตัดสินใจต่อได้ [5]
ส่วน Network Error เกิดก่อนจะได้รับ HTTP Response ที่ใช้งานได้ ดังนั้น requestJson() จะปล่อย Error จาก fetch() ขึ้นไป ไม่พยายามอ่าน status ที่ไม่มีอยู่
กรณี 204 No Content ต้องไม่เรียก response.json() เพราะไม่มี JSON body ให้ parse ตัวอย่างจึงคืน undefined โดยตรง
ฟังก์ชัน getTaskWithRetry() เป็นนโยบายสำหรับบทเรียน: ลองใหม่อีกหนึ่งครั้งเมื่อได้รับ 503 เท่านั้น การนำไปใช้จริงควรกำหนด retry policy, backoff และความปลอดภัยของการทำซ้ำตามประเภทคำขอ โดยเฉพาะคำขอที่เปลี่ยนข้อมูล
#6. กำหนดพฤติกรรม API ด้วย Handlers
ไฟล์ tests/mocks/handlers.js
import { http, HttpResponse } from 'msw/http';
const baseUrl = 'https://api.example.test';
const tasks = [
{ id: '1', title: 'เรียน Vitest', status: 'todo' },
{ id: '2', title: 'เขียน API tests', status: 'done' },
];
export const handlers = [
http.get(`${baseUrl}/tasks`, ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
return HttpResponse.json(
status ? tasks.filter((task) => task.status === status) : tasks,
);
}),
http.get(`${baseUrl}/tasks/:id`, ({ params }) => {
const task = tasks.find((item) => item.id === params.id);
return task
? HttpResponse.json(task)
: HttpResponse.json({ message: 'ไม่พบงาน' }, { status: 404 });
}),
http.post(`${baseUrl}/tasks`, async ({ request }) => {
if (request.headers.get('authorization') !== 'Bearer workshop-token') {
return HttpResponse.json({ message: 'ไม่มีสิทธิ์ใช้งาน' }, { status: 401 });
}
const body = await request.json();
if (typeof body.title !== 'string' || !body.title.trim()) {
return HttpResponse.json({ message: 'กรุณาระบุชื่องาน' }, { status: 422 });
}
// This fixture is stateless: POST does not change later GET responses.
return HttpResponse.json(
{ id: '3', title: body.title.trim(), status: 'todo' },
{ status: 201 },
);
}),
http.delete(`${baseUrl}/tasks/:id`, () => new HttpResponse(null, { status: 204 })),
];
#อ่านส่วนต่าง ๆ ของ Request
| ข้อมูล | วิธีอ่านใน Handler |
|---|---|
Path parameter เช่น /tasks/1 |
params.id |
Query parameter เช่น ?status=done |
new URL(request.url).searchParams.get('status') |
| JSON body | await request.json() |
| Authorization header | request.headers.get('authorization') |
Handler ใช้ URL ของ resource โดยไม่ใส่ query string ไว้ในตัวจับคู่ จากนั้นอ่าน query parameters ใน resolver แทน สำหรับ Node.js ตัวอย่างใช้ absolute URL ทั้งฝั่ง client และ Handler เพื่อให้ฐาน URL ตรงกันอย่างชัดเจน [6]
POST Handler จะคืน 401 เมื่อ token ไม่ถูกต้อง และคืน 422 เมื่อชื่องานเป็นค่าว่าง วิธีนี้ช่วยตรวจพฤติกรรมการส่งข้อมูลผ่านผลลัพธ์ของ API โดยไม่ต้องวาง expect() ไว้ภายใน Handler [7]
ขอบเขตข้อมูลจำลอง: Handler ชุดนี้เป็นแบบ stateless การ POST คืนรายการใหม่ แต่ไม่ได้บันทึกลง tasks และการ DELETE ไม่ได้ลบจาก array หากต้องทดสอบลำดับสร้าง→อ่าน→ลบ ต้องเพิ่ม state store และ reset ข้อมูลแยกจาก server.resetHandlers()
Token ในบทความเป็นข้อมูลสมมติ และการเทียบข้อความใน Handler ใช้กำหนดเงื่อนไขทดสอบฝั่ง client ไม่ใช่การตรวจสอบระบบ Authentication ของ Backend
#7. เชื่อม MSW เข้ากับวงจรการทดสอบ
ไฟล์ tests/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
ไฟล์ tests/setup.js
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './mocks/server.js';
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
| ช่วง | คำสั่ง | ผลที่ต้องการ |
|---|---|---|
| ก่อน Test ในไฟล์เริ่ม | server.listen(...) |
เปิดการดัก Requests |
| หลังแต่ละ Test | server.resetHandlers() |
ล้าง runtime overrides ที่เพิ่มด้วย server.use() |
| เมื่อ Test ในไฟล์เสร็จ | server.close() |
คืนสภาพ request interception |
Lifecycle นี้เป็นแนวทางของ MSW สำหรับ Node test runner โดย server.listen() เป็น synchronous API จึงไม่ต้อง await [2]
สำหรับ MSW 3 ใช้ onUnhandledFrame: 'error' เพื่อให้คำขอที่ไม่มี Handler เกิดข้อผิดพลาด ช่วยจับ URL หรือ HTTP method ที่เขียนผิด [3] อย่างไรก็ดี หากโค้ดที่ทดสอบจับ Error ทั้งหมดแล้วคืน fallback ตัว Test อาจยังผ่านได้ จึงต้อง assert ผลลัพธ์ที่ต้องการให้ชัดเจน และไม่ควรตั้ง catch-all Handler ที่กลบ endpoint ที่ไม่รู้จัก
#8. เขียน Test ครอบคลุมสถานการณ์หลัก
ไฟล์ tests/tasks-api.test.js
import { describe, expect, test } from 'vitest';
import { http, HttpResponse } from 'msw/http';
import { delay } from 'msw/utils/delay';
import {
createTask, deleteTask, getTask, getTaskWithRetry, listTasks,
} from '../src/tasks-api.js';
import { server } from './mocks/server.js';
const taskUrl = 'https://api.example.test/tasks/1';
describe('Tasks API through MSW', () => {
test('GET 200: returns a task', async () => {
await expect(getTask('1')).resolves.toEqual({
id: '1', title: 'เรียน Vitest', status: 'todo',
});
});
test('GET: reads query parameters', async () => {
await expect(listTasks('done')).resolves.toEqual([
{ id: '2', title: 'เขียน API tests', status: 'done' },
]);
});
test('GET 404: exposes a business error', async () => {
await expect(getTask('999')).rejects.toMatchObject({
name: 'HttpError', status: 404, message: 'ไม่พบงาน',
});
});
test('GET 500: overrides only this test', async () => {
server.use(http.get(taskUrl, () =>
HttpResponse.json({ message: 'ระบบขัดข้อง' }, { status: 500 }),
));
await expect(getTask('1')).rejects.toMatchObject({
name: 'HttpError', status: 500, message: 'ระบบขัดข้อง',
});
});
test('POST 201: sends JSON and an authorization header', async () => {
await expect(createTask(' ทดสอบ POST ', 'workshop-token')).resolves.toEqual({
id: '3', title: 'ทดสอบ POST', status: 'todo',
});
});
test('POST 401: rejects an invalid token', async () => {
await expect(createTask('ทดสอบ POST', 'wrong-token')).rejects.toMatchObject({
status: 401, message: 'ไม่มีสิทธิ์ใช้งาน',
});
});
test('POST 422: rejects an empty title', async () => {
await expect(createTask(' ', 'workshop-token')).rejects.toMatchObject({
status: 422, message: 'กรุณาระบุชื่องาน',
});
});
test('network failure: rejects without an HTTP status', async () => {
server.use(http.get(taskUrl, () => HttpResponse.error()));
await expect(getTask('1')).rejects.toBeInstanceOf(TypeError);
});
test('slow response: still returns a successful result', async () => {
server.use(http.get(taskUrl, async () => {
await delay(30);
return HttpResponse.json({ id: '1', title: 'ตอบช้า', status: 'todo' });
}));
await expect(getTask('1')).resolves.toMatchObject({ title: 'ตอบช้า' });
});
test('timeout: the caller aborts a delayed request', async () => {
server.use(http.get(taskUrl, async () => {
await delay(150);
return HttpResponse.json({ id: '1', title: 'ช้าเกินกำหนด', status: 'todo' });
}));
await expect(
getTask('1', { signal: AbortSignal.timeout(30) }),
).rejects.toMatchObject({ name: 'TimeoutError' });
});
test('DELETE 204: does not try to parse an empty JSON body', async () => {
await expect(deleteTask('1')).resolves.toBeUndefined();
});
test('retry: first response is 503, second response succeeds', async () => {
server.use(http.get(
taskUrl,
() => HttpResponse.json({ message: 'ลองใหม่' }, { status: 503 }),
{ once: true },
));
await expect(getTaskWithRetry('1')).resolves.toMatchObject({ id: '1' });
});
test('retry: does not retry HTTP 404', async () => {
let calls = 0;
server.use(http.get(taskUrl, () => {
calls += 1;
return HttpResponse.json({ message: 'ไม่พบงาน' }, { status: 404 });
}));
await expect(getTaskWithRetry('1')).rejects.toMatchObject({ status: 404 });
expect(calls).toBe(1);
});
test('resetHandlers: earlier overrides do not leak into this test', async () => {
await expect(getTask('1')).resolves.toMatchObject({ title: 'เรียน Vitest' });
});
});
#อ่านพฤติกรรมของ Test
- GET สำเร็จ: ตรวจข้อมูลที่ client ส่งกลับให้ผู้เรียก
- Query parameters: ขอเฉพาะงาน
doneแล้วตรวจรายการที่ได้รับ - 404: ตรวจสถานะและข้อความที่ผู้เรียกใช้แสดงผลได้
- 500: เพิ่ม Handler เฉพาะ Test เพื่อจำลองเซิร์ฟเวอร์ขัดข้อง
- POST: ตรวจผลการส่ง JSON และ token ผ่าน Response ของ Handler
- Network failure: ตรวจว่า Promise ปฏิเสธเมื่อไม่มี Response ปกติ
- Slow response / timeout: แยกการรอสำเร็จออกจากการยกเลิกคำขอ
- 204: ตรวจว่า client รองรับ Response ที่ไม่มี body
- Retry: ตรวจทั้งกรณีลองใหม่แล้วสำเร็จ และกรณี 404 ที่ไม่ควรลองใหม่
การนับจำนวน Request ใน Test ของ retry ใช้ตรวจนโยบายว่า 404 ต้องไม่ถูกลองซ้ำ ส่วน Test ทั่วไปเน้นข้อมูลและ Error ที่ผู้เรียกสังเกตได้
#9. server.use() เปลี่ยนเฉพาะกรณีที่ต้องการทดสอบ
Handlers ใน setupServer(...handlers) เป็นพฤติกรรมเริ่มต้น ส่วน Handlers ที่เพิ่มด้วย server.use() ได้รับลำดับความสำคัญก่อน จึงใช้เปลี่ยน endpoint เดิมให้ตอบสถานะอื่นใน Test นั้นได้ [8]
เมื่อระบุ { once: true } Handler จะทำงานเพียงหนึ่งครั้ง คำขอต่อมาจึงตกไปใช้ Handler ปกติ ตัวอย่าง retry ในชุดทดสอบจึงจำลองลำดับ 503 → 200 ได้โดยไม่เขียนตัวนับเพื่อเปลี่ยน Response เอง [8]
server.resetHandlers() ใน afterEach() ล้าง runtime overrides เพื่อไม่ให้ Test ถัดไปได้รับผลจากสถานการณ์ก่อนหน้า แต่ไม่ได้ล้างตัวแปรภายนอก, database จำลอง, spy หรือ cache ของ API client ต้องคืนสภาพสิ่งเหล่านั้นด้วยกลไกของมันเอง
หลีกเลี่ยง test.concurrent() ในกลุ่ม Test ที่แก้ Handlers บน server instance เดียวกัน หากยังไม่ได้ออกแบบการแยก context เพราะคำขอของแต่ละ Test อาจพบ override ของอีก Test ได้ ตัวอย่างนี้รันแต่ละ Test ในไฟล์ตามลำดับปกติ
#10. HTTP Error, Network Error และ Timeout ต่างกันอย่างไร
| เหตุการณ์ | ตัวอย่างการจำลอง | พฤติกรรมของ client ใน Workshop |
|---|---|---|
| HTTP Error | HttpResponse.json(body, { status: 500 }) |
ได้ Response แล้ว requestJson() สร้าง HttpError |
| Network Error | HttpResponse.error() |
fetch() ปฏิเสธ; ใน Node ที่ใช้ทดสอบเป็น TypeError |
| Slow response | await delay(30) แล้วคืน JSON |
Promise รอจนสำเร็จ หากไม่มีการยกเลิก |
| Timeout ของผู้เรียก | delay(150) ร่วมกับ AbortSignal.timeout(30) |
คำขอถูกยกเลิก; ในตัวอย่าง Node เป็น TimeoutError |
| HTTP 408/504 | คืน Response ที่มีสถานะ 408/504 | ยังเป็น HTTP Response และเข้าสู่ทางจัดการ HTTP Error |
HttpResponse.error() ใช้จำลองความล้มเหลวทางเครือข่าย และไม่ให้กำหนดข้อความ Error แบบ HTTP body [9] จึงไม่ควรผูก Test กับข้อความ เช่น Failed to fetch ที่อาจต่างกันตาม client และ runtime
ใช้ delay() พร้อมตัวเลขเมื่อทดสอบ Node.js เพราะ implicit delay แบบไม่ระบุค่าไม่ได้มีไว้ทำให้ Node test ช้าลง [10] และอย่าวัดเวลาว่าต้องเท่ากับ 30 มิลลิวินาทีเป๊ะ เพราะ scheduler ของเครื่องมีผลต่อเวลาที่สังเกตได้
AbortSignal.timeout() สร้าง signal ที่ยกเลิกเมื่อครบกำหนด และต้องส่งผ่านไปถึง fetch() จึงจะยกเลิกคำขอได้ [5] การเพิ่ม testTimeout ของ Vitest เพียงเปลี่ยนระยะเวลาที่ Test runner รอ ไม่ได้เพิ่ม timeout ให้ HTTP client
ชุดทดสอบนี้ใช้เวลาหน่วงสั้นและ real timers เพื่อให้ติดตามลำดับได้ง่าย หากต้องทดสอบ backoff หลายรอบด้วย fake timers ควรแยกนโยบายเวลาออกเป็นส่วนที่ทดสอบได้ และตรวจว่าตัวจับเวลาในทุก dependency ถูกขับเคลื่อนไปครบ
#11. เปรียบเทียบกับการ Stub fetch() โดยตรง
ตัวอย่างต่อไปนี้ใช้ vi.stubGlobal() แทนฟังก์ชัน fetch ด้วย mock และใช้ vi.unstubAllGlobals() คืนค่าหลังแต่ละ Test [11]
ไฟล์ tests/fetch-stub.test.js
import { afterEach, expect, test, vi } from 'vitest';
import { getTask } from '../src/tasks-api.js';
afterEach(() => vi.unstubAllGlobals());
test('unit test: replaces global fetch directly', async () => {
const fetchMock = vi.fn().mockImplementation(async () =>
new Response(JSON.stringify({ id: '1', title: 'ใช้ fetch stub', status: 'todo' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(getTask('1')).resolves.toMatchObject({ title: 'ใช้ fetch stub' });
expect(fetchMock).toHaveBeenCalledWith('https://api.example.test/tasks/1', {});
});
แม้ไฟล์นี้ใช้ setup กลางเดียวกับชุด MSW แต่ใน Test นี้ fetch ถูกแทนด้วย stub จึงไม่ได้ทดสอบเส้นทางการจับคู่ Handler ของ MSW ตัวอย่างมีไว้เปรียบเทียบสองวิธีในโปรเจกต์เดียวกัน
เลือกคืน new Response(...) แทน object ที่มีเพียง json: vi.fn() เพื่อให้ client ยังอ่าน status, ok และ body ตาม API มาตรฐาน และสร้าง Response ใหม่ทุกครั้งใน mock implementation เพื่อไม่ให้เกิดปัญหาอ่าน body ซ้ำ
vi.restoreAllMocks() ใช้คืน spies ที่สร้างด้วย vi.spyOn() ส่วน global ที่เปลี่ยนผ่าน vi.stubGlobal() ให้ใช้ vi.unstubAllGlobals() โดยตรง การล้าง Handler ของ MSW ไม่ได้คืนค่า global stub [11]
#12. รันและอ่านผลการทดสอบ
# รันทั้งหมดหนึ่งรอบ เหมาะกับ CI
npm test
# Watch mode
npm run test:watch
# เฉพาะชุด MSW
npx vitest run tests/tasks-api.test.js
# เฉพาะ fetch stub
npx vitest run tests/fetch-stub.test.js
# ตรวจสอบเวอร์ชันของแพ็กเกจที่ติดตั้ง
npm ls vitest msw
vitest run รันหนึ่งรอบ ส่วน vitest ใช้ watch mode ตามการใช้งานที่เอกสารแนะนำ [4]
ผลตรวจสอบ Workshop บน Node.js 24.19.0: ผ่านทั้งหมด 15 Tests ใน 2 ไฟล์ (MSW 14 กรณี + fetch stub 1 กรณี)
Test Files 2 passed (2)
Tests 15 passed (15)
สภาพแวดล้อมที่ใช้ตรวจสอบเปิด environment HTTP proxy จึงเพิ่ม api.example.test ใน proxy exclusion เฉพาะ process ทดสอบก่อนรัน หากเครื่องอบรมมีลักษณะเดียวกัน ใช้ Bash ดังนี้ โดยเก็บ exclusion เดิมไว้:
NO_PROXY="${NO_PROXY:+$NO_PROXY,}api.example.test" no_proxy="${no_proxy:+$no_proxy,}api.example.test" npm test
เครื่องทั่วไปที่ไม่ได้เปิด environment proxy ใช้ npm test ได้ตามปกติ
Test ที่ผ่านหมายความว่า client ทำงานสอดคล้องกับเงื่อนไขจำลองที่กำหนด ไม่ได้ยืนยันว่า Backend จริงตรงกับ Handler ทุกข้อ ควรมี integration tests กับ API จริงและ contract checks ในขอบเขตที่เหมาะสม
#13. ใช้กับ React, Vue และ Browser Mode
| สภาพแวดล้อมทดสอบ | แนวทาง MSW |
|---|---|
Vitest environment: 'node' |
setupServer() |
Vitest jsdom / happy-dom ที่รันใน Node |
ใช้แนวทาง Node integration และตรวจ Web APIs ที่ dependency ต้องใช้ |
| Vitest Browser Mode ในเบราว์เซอร์จริง | ใช้ Browser integration เช่น setupWorker() และเริ่ม worker ก่อนส่ง Requests |
jsdom เป็น DOM จำลองใน Node process จึงต้องแยกจาก Browser Mode ที่รันในเบราว์เซอร์จริง เอกสาร MSW แยกสองแนวทางนี้ไว้ชัดเจน และแนะนำให้ดู Vite integration หากใช้ร่วมกับ Vite [12]
เมื่อนำไปใช้กับ Component ให้เพิ่มการตรวจสิ่งที่ผู้ใช้เห็น เช่น สถานะกำลังโหลด, ข้อมูลหลังสำเร็จ, ข้อความเมื่อเกิดปัญหา และผลหลังผู้ใช้กดลองใหม่ Workshop นี้ครอบคลุม API client จึงยังไม่มี UI tests ของสถานะเหล่านั้น
#14. ปัญหาที่พบบ่อย
| อาการ | จุดที่ควรตรวจ |
|---|---|
Import http ไม่สำเร็จ |
MSW 3 ต้องใช้ msw/http; ตรวจ major version |
| Unknown request ยังไม่ถูกบล็อกตามคาด | MSW 3 ใช้ onUnhandledFrame และต้องโหลด setup file |
Failed to parse URL |
Node fetch ต้องได้รับ URL ที่ resolve ได้; ตัวอย่างใช้ absolute URL |
| Handler ไม่ทำงาน | ตรวจ scheme, host, pathname และ HTTP method ให้ตรง |
| Query filter ไม่ได้ผล | อ่าน request.url ใน resolver; ไม่ใส่ query ไว้ใน predicate |
| POST อ่าน body ไม่ได้ | ตรวจ JSON.stringify() และ Content-Type ของ client |
| Test ถัดไปยังได้รับ 500 | ตรวจ afterEach(server.resetHandlers) และ state ภายนอก |
Unexpected end of JSON input |
ตรวจ 204 หรือ response body ว่างก่อน parse JSON |
| Test แขวนเมื่อใช้ fake timers | ตรวจ timer advancement และคำขอที่ยังไม่เสร็จ |
| CI ติดตั้งไม่ผ่าน | ใช้ Node รุ่นที่รองรับ และ commit package-lock.json |
| Test ผ่านแต่ระบบจริงผิด | ตรวจว่า fixture และ API specification ยังตรงกัน |
ควรให้ API specification เช่น OpenAPI เป็นแหล่งข้อมูลร่วมสำหรับ Backend และ mocks เพื่อลดปัญหา mock drift และทบทวน Handler เมื่อรูปแบบ Response เปลี่ยน [13]
#15. แบบฝึกหัดสำหรับอบรม
| ข้อ | งานที่ให้ทำ | เกณฑ์ตรวจ |
|---|---|---|
| 1 | เพิ่ม PUT /tasks/:id |
ส่ง JSON แล้ว client ได้ข้อมูลที่แก้ไขตามสัญญา API |
| 2 | เพิ่มกรณี 403 | client แยก forbidden ออกจากข้อมูลไม่พบได้ |
| 3 | จำลอง Response 200 ที่ไม่ใช่ JSON | client แสดงหรือส่งต่อปัญหาการ parse ตามนโยบาย |
| 4 | ทดสอบ 503 สองครั้งติดกัน | retry หยุดหลังจำนวนครั้งที่กำหนด |
| 5 | เพิ่ม stateful mock สำหรับ POST→GET | Test อื่นเริ่มด้วยข้อมูลตั้งต้นเดิมทุกครั้ง |
| 6 | ต่อ Component เข้ากับ API client | ตรวจ loading, success และ error จากสิ่งที่ผู้ใช้เห็น |
แนะนำเวลาอบรมประมาณ 60–90 นาที: อธิบายแนวคิด 10 นาที, ตั้งค่าและรันกรณีสำเร็จ 20 นาที, ทดลอง Error และ timeout 20 นาที แล้วใช้เวลาที่เหลือทำแบบฝึกหัด
#เอกสารอ้างอิง
- Vitest — Mocking Requests
- MSW — Node.js integration
- MSW — Migration 2.x → 3.x
- Vitest — Getting Started
- Node.js — Global objects: fetch และ AbortSignal
- MSW — Intercepting requests
- MSW — Avoid request assertions
- MSW — Network behavior overrides
- MSW — HttpResponse
- MSW — delay
- Vitest — Vi utility
- MSW — Vitest Browser Mode integration
- MSW — Preventing mock drift
ตัวอย่างระบบรายการงานและแบบฝึกหัดเขียนขึ้นสำหรับบทความนี้ ใช้ข้อมูลสมมติทั้งหมด