Mock API dengan MSW dan Claude Code: panduan praktis
Bangun mock API realistis dengan MSW dan Claude Code untuk browser, tes Node, auth, error, dan CI.
MSW adalah singkatan dari Mock Service Worker. Di browser, MSW mencegat request HTTP melalui Service Worker. Di tes Node.js, MSW mencegat modul yang mengirim request di proses yang sama. Hasilnya, pengembangan lokal, Vitest, dan CI dapat memakai handler API yang sama.
Saat memakai Claude Code, jangan hanya meminta JSON statis. Mock yang berguna harus mencakup autentikasi, paginasi, validasi, status error, kegagalan jaringan, dan pemeriksaan kontrak response. Jika mock selalu mengembalikan 200 OK, UI terlihat selesai, tetapi kasus produksi yang penting tetap belum diuji.
Panduan ini memakai API MSW 2 yang ada di dokumentasi resmi: http, HttpResponse, setupWorker, dan setupServer. Mulai dari MSW Quick start, lalu baca Browser integration dan Node.js integration. Untuk kegagalan, lihat error responses dan network errors.
Untuk konteks tambahan, baca teknik Vitest lanjutan, tes E2E Playwright, otomasi tes API, dan pengaturan CI/CD.
Kapan Dipakai
| Kasus | Yang dimock | Risiko jika dilewati |
|---|---|---|
| UI sebelum backend siap | Daftar, detail, buat data, state kosong | UI tidak cocok dengan kontrak API nyata |
| Auth dan role | 401, 403, response per role | Aksi admin terlihat oleh user biasa |
| UX saat gagal | 500, 422, jaringan gagal, lambat | Loading tidak selesai atau tombol retry tidak ada |
| Kontrak di CI | Bentuk JSON, field wajib, status code | Perubahan API lolos ke produksi tanpa sinyal |
Prompt yang lebih aman untuk Claude Code:
Buat mock API users dengan MSW 2.
Browser development dan Vitest di Node memakai handlers.ts yang sama.
Sertakan auth wajib, paginasi, filter role, 422, 404, 500, dan tes network error.
Gunakan TypeScript dan jangan tinggalkan type aplikasi yang belum didefinisikan.
Arsitektur
flowchart LR
UI["UI browser"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["MSW handlers.ts"]
Server --> Handlers
Handlers --> Contract["Kontrak API: status / JSON / auth / delay"]
Instalasi
npm i -D msw vitest typescript
npx msw init public/ --save
Saat server lokal berjalan, buka http://localhost:5173/mockServiceWorker.js. Jika 404, browser tidak akan mencegat request.
Handler yang Bisa Disalin
Contoh ini menyediakan list, detail, create, update, dan delete user. Di dalamnya ada auth, paginasi, validasi, 404, dan delay. URL absolut membuatnya mudah dipakai dengan fetch native di Node.
import { delay, http, HttpResponse } from "msw";
export const API_ORIGIN = "https://api.example.test";
type Role = "admin" | "editor" | "viewer";
export type User = {
id: string;
name: string;
email: string;
role: Role;
};
type CreateUserInput = {
name: string;
email: string;
role?: Role;
};
type ErrorBody = {
error: {
code: string;
message: string;
requestId: string;
};
};
type PageMeta = {
total: number;
page: number;
perPage: number;
};
type UserListResponse = {
data: User[];
meta: PageMeta;
};
const seedUsers: User[] = [
{ id: "u_1", name: "Aki Tanaka", email: "aki@example.com", role: "admin" },
{ id: "u_2", name: "Bea Sato", email: "bea@example.com", role: "editor" },
{ id: "u_3", name: "Cal Mori", email: "cal@example.com", role: "viewer" },
];
let users: User[] = [...seedUsers];
const jsonError = (status: number, code: string, message: string) =>
HttpResponse.json(
{ error: { code, message, requestId: "req_mock_001" } },
{ status }
);
const requireAuth = (request: Request) => {
const token = request.headers.get("authorization");
return token === "Bearer demo-token"
? null
: jsonError(401, "UNAUTHORIZED", "Missing or invalid bearer token");
};
const isRole = (value: string | null): value is Role =>
value === "admin" || value === "editor" || value === "viewer";
export function resetMockData() {
users = [...seedUsers];
}
export const handlers = [
http.get(`${API_ORIGIN}/users`, async ({ request }) => {
const authError = requireAuth(request);
if (authError) return authError;
await delay(120);
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const perPage = Number(url.searchParams.get("perPage") ?? "20");
const role = url.searchParams.get("role");
if (!Number.isInteger(page) || page < 1) {
return jsonError(422, "INVALID_PAGE", "page must be a positive integer");
}
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 50) {
return jsonError(422, "INVALID_PER_PAGE", "perPage must be between 1 and 50");
}
if (role && !isRole(role)) {
return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
}
const filtered = role ? users.filter((user) => user.role === role) : users;
const start = (page - 1) * perPage;
return HttpResponse.json({
data: filtered.slice(start, start + perPage),
meta: { total: filtered.length, page, perPage },
});
}),
http.get(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
const authError = requireAuth(request);
if (authError) return authError;
await delay(80);
const user = users.find((item) => item.id === String(params.id));
return user
? HttpResponse.json({ data: user })
: jsonError(404, "USER_NOT_FOUND", "User was not found");
}),
http.post(`${API_ORIGIN}/users`, async ({ request }) => {
const authError = requireAuth(request);
if (authError) return authError;
const body = (await request.json()) as Partial<CreateUserInput>;
if (!body.name?.trim() || !body.email?.includes("@")) {
return jsonError(422, "INVALID_INPUT", "name and a valid email are required");
}
if (body.role && !isRole(body.role)) {
return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
}
const user: User = {
id: `u_${Date.now()}`,
name: body.name.trim(),
email: body.email,
role: body.role ?? "viewer",
};
users = [user, ...users];
return HttpResponse.json({ data: user }, { status: 201 });
}),
http.patch(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
const authError = requireAuth(request);
if (authError) return authError;
const index = users.findIndex((item) => item.id === String(params.id));
if (index === -1) return jsonError(404, "USER_NOT_FOUND", "User was not found");
const body = (await request.json()) as Partial<CreateUserInput>;
if (body.email && !body.email.includes("@")) {
return jsonError(422, "INVALID_EMAIL", "email must include @");
}
if (body.role && !isRole(body.role)) {
return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
}
users[index] = { ...users[index], ...body };
return HttpResponse.json({ data: users[index] });
}),
http.delete(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
const authError = requireAuth(request);
if (authError) return authError;
users = users.filter((item) => item.id !== String(params.id));
return new HttpResponse(null, { status: 204 });
}),
];
Setup Browser
Gunakan setupWorker dan tunggu worker.start() sebelum merender aplikasi.
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
async function enableMocking() {
if (!import.meta.env.DEV || import.meta.env.VITE_API_MOCKING !== "enabled") {
return;
}
const { worker } = await import("./mocks/browser");
await worker.start({
onUnhandledRequest: "bypass",
});
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
});
Aktifkan dengan jelas memakai VITE_API_MOCKING=enabled npm run dev. Di produksi, login, pembelian, formulir, dan CTA monetisasi harus memakai layanan nyata.
Vitest dan CI
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { API_ORIGIN, handlers, resetMockData } from "../src/mocks/handlers";
const server = setupServer(...handlers);
function authed(input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
headers.set("authorization", "Bearer demo-token");
return fetch(input, { ...init, headers });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
server.resetHandlers();
resetMockData();
});
afterAll(() => server.close());
describe("users API mock", () => {
it("returns a paginated user list", async () => {
const response = await authed(`${API_ORIGIN}/users?page=1&perPage=2`);
const body = (await response.json()) as {
data: Array<Record<string, unknown>>;
meta: Record<string, unknown>;
};
expect(response.status).toBe(200);
expect(body.data).toHaveLength(2);
expect(body.meta).toMatchObject({ total: 3, page: 1, perPage: 2 });
});
it("rejects missing auth", async () => {
const response = await fetch(`${API_ORIGIN}/users`);
const body = (await response.json()) as { error: { code: string } };
expect(response.status).toBe(401);
expect(body.error.code).toBe("UNAUTHORIZED");
});
it("simulates a network failure for retry UI", async () => {
server.use(
http.get(`${API_ORIGIN}/users`, () => {
return HttpResponse.error();
})
);
await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
});
it("guards against response contract drift", async () => {
const response = await authed(`${API_ORIGIN}/users`);
const body = (await response.json()) as {
data: Array<Record<string, unknown>>;
meta: Record<string, unknown>;
};
expect(Object.keys(body.data[0]).sort()).toEqual(["email", "id", "name", "role"]);
expect(body.data[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
email: expect.stringContaining("@"),
})
);
expect(body.meta).toEqual(expect.objectContaining({ page: 1, perPage: 20 }));
});
});
name: msw-contract
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test -- --run
Kesalahan Umum
Pertama, mockServiceWorker.js tidak tersedia. Jika file ini 404, browser tidak akan mencegat request.
Kedua, state bocor antar tes. Jalankan server.resetHandlers() dan reset data memori setelah setiap tes.
Ketiga, memakai onUnhandledRequest: "bypass" di CI. Pada tes, request yang tidak dimock harus membuat tes gagal.
Keempat, tidak memodelkan auth. Banyak bug muncul di antara sesi valid, sesi kedaluwarsa, izin kurang, dan role yang salah.
Kelima, tidak memeriksa kontrak response. Validasi data, meta.total, dan error.code, bukan hanya status HTTP.
CTA Monetisasi
MSW sangat berguna untuk jalur pendapatan: CTA artikel, pembelian produk, formulir kontak, pendaftaran gratis, dan pratinjau checkout. Simulasikan 500, response lambat, auth kedaluwarsa, dan error validasi sebelum rilis. Untuk menjadikan alur ini sebagai prompt dan checklist Claude Code yang bisa dipakai ulang, lihat produk atau pelatihan Claude Code.
Hasil Praktik
Saat Masa mencoba struktur ini pada alur CTA dan produk, bagian paling berguna adalah HttpResponse.error() dan onUnhandledRequest: "error". Mock yang hanya sukses tidak menemukan tombol retry yang hilang, header auth yang tidak terkirim, dan meta.total yang terhapus. Handler yang sama di pengembangan lokal dan CI membuat kegagalan mudah direproduksi.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission safety ladder Claude Code: perluas akses tanpa kehilangan kontrol
Naik dari read-only ke edit terbatas, command bukti, dan cek deploy dengan kontrol yang jelas.
Claude Code Small PR Proof Pack: perubahan kecil yang mudah direview
Paket bukti untuk PR Claude Code: diff, check, URL publik, jalur CTA, dan rollback.
Review gate Claude Code sebelum commit: diff, test, URL publik, dan CTA
Cara memakai Claude Code sebelum commit: diff scope, build, URL publik, link Gumroad, CTA konsultasi, missing test, dan file tidak terkait.