تست‌نویسی در javascript/typescript — بخش ۷: MSW و پروژه Todo List

تست‌نویسی در javascript/typescript — بخش ۷: MSW و پروژه Todo List

دی ۲۶, ۱۴۰۴

توی بخش ۶ یاد گرفتیم با vi.fn() و global.fetch =... شبکه رو mock کنیم. کار می‌کنه — ولی وقتی API زیاد بشه، هر تست پر می‌شه از کد تکراری mock.

راه‌حل بهتر این است: Mock Service Worker (MSW).

MSW درخواست‌های شبکه رو قبل از رسیدن به اینترنت می‌گیره و جواب fake برمی‌گردونه. کد شما همون fetch معمولی رو می‌زنه — فقط جواب از mock می‌آد.

توی این مقاله MSW رو یاد می‌گیریم و بعد یه Todo List با React می‌سازیم .

خب بزن بریم!


MSW چیه و چرا بهتر از mock دستی fetch؟

مشکل mock دستی (بخش ۶)

// هر فایل تست دوباره این رو می‌نویسه...
global.fetch = vi.fn(() => Promise.resolve({ json: () => ... }));
  • تکرار زیاد
  • فراموش کردن restore
  • سخت شدن نگهداری وقتی endpoint زیاد می‌شه

راه‌حل MSW

  • یک جا handlerها رو تعریف می‌کنی (/api/todos، /api/login،...)
  • کد production عوض نمی‌شه — همون fetch('/api/todos')
  • نزدیک‌تر به دنیای واقعی — فقط لایه شبکه intercept می‌شه
mock دستی fetch MSW
محل تعریف هر تست فایل handlers مرکزی
کد app بدون تغییر بدون تغییر
چند endpoint شلوغ منظم
توصیه تست کوچک پروژه واقعی با API

نصب و راه‌اندازی MSW

npm install --save-dev msw

ساختار پوشه

src/
├── mocks/
│ ├── handlers.js # تعریف endpointها
│ └── server.js # سرور تست
├── components/
│ └── ToDoList.jsx
└── tests/
 └── setupTests.js

handlers — src/mocks/handlers.js

import { rest } from 'msw';

export const handlers = [
 rest.get('/api/user', (req, res, ctx) => {
 return res(ctx.status(200), ctx.json({ id: '123', name: 'John Doe' }));
 }),

 rest.post('/api/login', (req, res, ctx) => {
 const { username } = req.body;
 return res(ctx.status(200), ctx.json({ message: `Welcome, ${username}!` }));
 }),
];

server — src/mocks/server.js

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

setupTests — tests/setupTests.js

import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../src/mocks/server';
import '@testing-library/jest-dom/vitest';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

onUnhandledRequest: 'error' یعنی اگر تستی به APIای زد که handler نداریم، fail بشه — خوبه برای پیدا کردن باگ!

vitest.config.js

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
 plugins: [react()],
 test: {
 globals: true,
 environment: 'jsdom',
 setupFiles: './tests/setupTests.js',
 },
});

اولین تست با MSW — UserProfile

یه کامپوننت ساده نشون می‌ده:

// UserProfile.jsx
import { useEffect, useState } from 'react';

export function UserProfile() {
 const [user, setUser] = useState(null);

 useEffect(() => {
 fetch('/api/user')
 .then((res) => res.json())
 .then(setUser);
 }, []);

 if (!user) return <div>Loading…</div>;

 return (
 <div>
 <h1>{user.name}</h1>
 </div>
 );
}
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { UserProfile } from './UserProfile';

test('renders user profile after fetching data', async () => {
 render(<UserProfile />);

 expect(screen.getByText(/loading/i)).toBeInTheDocument();

 const userName = await screen.findByText('John Doe');
 expect(userName).toBeInTheDocument();
});

بدون هیچ vi.fn روی fetch! handler در handlers.js جواب { name: 'John Doe' } رو می‌ده.


پروژه Todo List — شروع

حالا مثال اصلی: Todo List با React + TDD + MSW.

نصب

npm create vite@latest todo-app-tdd -- --template react
cd todo-app-tdd
npm install
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw

API که شبیه‌سازی می‌کنیم

Method Endpoint کار
GET /api/todos لیست todoها
POST /api/todos اضافه کردن
PUT /api/todos/:id آپدیت (مثلاً completed)
DELETE /api/todos/:id حذف

داده mock

const mockTodos = [
 { id: 1, title: 'Buy groceries', completed: false },
 { id: 2, title: 'Walk the dog', completed: true },
];

مرحله ۱: نمایش لیست Todo (TDD)

تست (قرمز)

// src/tests/ToDoList.test.jsx
import { render, screen } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ToDoList } from '../components/ToDoList';

const mockTodos = [
 { id: 1, title: 'Buy groceries', completed: false },
 { id: 2, title: 'Walk the dog', completed: true },
];

const server = setupServer(
 rest.get('/api/todos', (req, res, ctx) => {
 return res(ctx.json(mockTodos));
 }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('renders to-do items fetched from API', async () => {
 render(<ToDoList />);

 expect(screen.getByText(/loading/i)).toBeInTheDocument();

 const items = await screen.findAllByRole('listitem');
 expect(items).toHaveLength(2);
 expect(screen.getByText('Buy groceries')).toBeInTheDocument();
 expect(screen.getByText('Walk the dog')).toBeInTheDocument();
});

کد (سبز) — ToDoList.jsx

import { useEffect, useState } from 'react';

export function ToDoList() {
 const [todos, setTodos] = useState([]);
 const [loading, setLoading] = useState(true);

 useEffect(() => {
 fetch('/api/todos')
 .then((res) => res.json())
 .then((data) => {
 setTodos(data);
 setLoading(false);
 });
 }, []);

 if (loading) return <div>Loading…</div>;

 return (
 <ul>
 {todos.map((todo) => (
 <li key={todo.id}>
 {todo.title} {todo.completed ? '(Completed)' : ''}
 </li>
 ))}
 </ul>
 );
}

تست pass می‌شه. ساده بود!


مرحله ۲: اضافه کردن Todo جدید

تست

import userEvent from '@testing-library/user-event';

test('adds a new to-do item', async () => {
 const user = userEvent.setup();
 render(<ToDoList />);

 await screen.findByText('Buy groceries');

 const input = screen.getByPlaceholderText('Add new to-do');
 const addButton = screen.getByRole('button', { name: /add/i });

 server.use(
 rest.post('/api/todos', (req, res, ctx) => {
 return res(ctx.status(201), ctx.json({ id: 3, title: 'Learn TDD', completed: false }));
 }),
 );

 server.use(
 rest.get('/api/todos', (req, res, ctx) => {
 return res(ctx.json([...mockTodos, { id: 3, title: 'Learn TDD', completed: false }]));
 }),
 );

 await user.type(input, 'Learn TDD');
 await user.click(addButton);

 expect(await screen.findByText('Learn TDD')).toBeInTheDocument();
});

نکته: server.use برای یک تست

server.use handler رو موقتاً عوض می‌کنه — بعد resetHandlers در afterEach برمی‌گرده.

کد به‌روز شده

export function ToDoList() {
 const [todos, setTodos] = useState([]);
 const [loading, setLoading] = useState(true);
 const [newTodo, setNewTodo] = useState('');

 useEffect(() => {
 fetchTodos();
 }, []);

 function fetchTodos() {
 fetch('/api/todos')
 .then((res) => res.json())
 .then((data) => {
 setTodos(data);
 setLoading(false);
 });
 }

 function addTodo() {
 fetch('/api/todos', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ title: newTodo }),
 }).then(() => {
 setNewTodo('');
 fetchTodos();
 });
 }

 if (loading) return <div>Loading…</div>;

 return (
 <div>
 <input
 placeholder="Add new to-do"
 value={newTodo}
 onChange={(e) => setNewTodo(e.target.value)}
 />
 <button type="button" onClick={addTodo}>Add</button>
 <ul>
 {todos.map((todo) => (
 <li key={todo.id}>
 {todo.title} {todo.completed ? '(Completed)' : ''}
 </li>
 ))}
 </ul>
 </div>
 );
}

مرحله ۳: علامت‌زدن به‌عنوان انجام‌شده

تست (قرمز)

test('marks a to-do item as completed', async () => {
 const user = userEvent.setup();
 render(<ToDoList />);

 const itemCheckbox = await screen.findByRole('checkbox', { name: 'Buy groceries' });
 expect(itemCheckbox).not.toBeChecked();

 server.use(
 rest.put('/api/todos/1', (req, res, ctx) => {
 return res(ctx.json({ id: 1, title: 'Buy groceries', completed: true }));
 }),
 );

 await user.click(itemCheckbox);

 expect(itemCheckbox).toBeChecked();
});

اول fail می‌شه — چون checkbox هنوز نیست.

کد (سبز) — toggleComplete

function toggleComplete(todo) {
 fetch(`/api/todos/${todo.id}`, {
 method: 'PUT',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ ...todo, completed: !todo.completed }),
 }).then(() => {
 fetchTodos();
 });
}

// در JSX:
<ul>
 {todos.map((todo) => (
 <li key={todo.id}>
 <label>
 <input
 type="checkbox"
 checked={todo.completed}
 onChange={() => toggleComplete(todo)}
 aria-label={todo.title}
 />
 {todo.title}
 </label>
 </li>
 ))}
</ul>

تست pass می‌شه. نکته: aria-label={todo.title} مهمه — Testing Library با اسم checkbox پیداش می‌کنه.


مرحله ۴: حذف Todo

تست (قرمز)

test('deletes a to-do item', async () => {
 const user = userEvent.setup();
 render(<ToDoList />);

 const deleteButton = await screen.findByRole('button', { name: 'Delete Buy groceries' });

 server.use(
 rest.delete('/api/todos/1', (req, res, ctx) => {
 return res(ctx.status(200));
 }),
 );

 server.use(
 rest.get('/api/todos', (req, res, ctx) => {
 return res(ctx.json(mockTodos.filter((todo) => todo.id !== 1)));
 }),
 );

 await user.click(deleteButton);

 expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
});

کد (سبز) — deleteTodo

function deleteTodo(id) {
 fetch(`/api/todos/${id}`, {
 method: 'DELETE',
 }).then(() => {
 fetchTodos();
 });
}

// در JSX:
<button
 type="button"
 onClick={() => deleteTodo(todo.id)}
 aria-label={`Delete ${todo.title}`}
>
 Delete
</button>

پیشنهاد می‌کنه اگر کامپوننت شلوغ شد، API callها رو ببری توی یه فایل todoService.js — ولی اول TDD رو تموم کن، بعد refactor.


کامپوننت نهایی ToDoList — یکجا

import { useEffect, useState } from 'react';

export function ToDoList() {
 const [todos, setTodos] = useState([]);
 const [loading, setLoading] = useState(true);
 const [newTodo, setNewTodo] = useState('');

 useEffect(() => {
 fetchTodos();
 }, []);

 function fetchTodos() {
 fetch('/api/todos')
 .then((res) => res.json())
 .then((data) => {
 setTodos(data);
 setLoading(false);
 });
 }

 function addTodo() {
 fetch('/api/todos', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ title: newTodo }),
 }).then(() => {
 setNewTodo('');
 fetchTodos();
 });
 }

 function toggleComplete(todo) {
 fetch(`/api/todos/${todo.id}`, {
 method: 'PUT',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ ...todo, completed: !todo.completed }),
 }).then(() => fetchTodos());
 }

 function deleteTodo(id) {
 fetch(`/api/todos/${id}`, { method: 'DELETE' }).then(() => fetchTodos());
 }

 if (loading) return <div>Loading…</div>;

 return (
 <div>
 <input
 placeholder="Add new to-do"
 value={newTodo}
 onChange={(e) => setNewTodo(e.target.value)}
 />
 <button type="button" onClick={addTodo}>Add</button>
 <ul>
 {todos.map((todo) => (
 <li key={todo.id}>
 <label>
 <input
 type="checkbox"
 checked={todo.completed}
 onChange={() => toggleComplete(todo)}
 aria-label={todo.title}
 />
 {todo.title}
 </label>
 <button
 type="button"
 onClick={() => deleteTodo(todo.id)}
 aria-label={`Delete ${todo.title}`}
 >
 Delete
 </button>
 </li>
 ))}
 </ul>
 </div>
 );
}

چهار feature: خواندن، اضافه، complete، حذف — همه با TDD.


handlers مرکزی برای Todo

به جای تعریف server در هر فایل تست، می‌تونید همه رو بذارید توی handlers.js:

// src/mocks/handlers.js
import { rest } from 'msw';

let todos = [
 { id: 1, title: 'Buy groceries', completed: false },
 { id: 2, title: 'Walk the dog', completed: true },
];

export const handlers = [
 rest.get('/api/todos', (req, res, ctx) => {
 return res(ctx.json(todos));
 }),

 rest.post('/api/todos', async (req, res, ctx) => {
 const { title } = await req.json();
 const newTodo = { id: Date.now(), title, completed: false };
 todos.push(newTodo);
 return res(ctx.status(201), ctx.json(newTodo));
 }),

 rest.put('/api/todos/:id', async (req, res, ctx) => {
 const { id } = req.params;
 const body = await req.json();
 todos = todos.map((t) => (t.id === Number(id) ? { ...t, ...body } : t));
 const updated = todos.find((t) => t.id === Number(id));
 return res(ctx.json(updated));
 }),

 rest.delete('/api/todos/:id', (req, res, ctx) => {
 const { id } = req.params;
 todos = todos.filter((t) => t.id !== Number(id));
 return res(ctx.status(204));
 }),
];

توی beforeEach می‌تونید todos رو reset کنید تا تست‌ها مستقل بمونن.


تست خطای API با MSW

test('shows error when API fails', async () => {
 server.use(
 rest.get('/api/todos', (req, res, ctx) => {
 return res(ctx.status(500), ctx.json({ error: 'Server error' }));
 }),
 );

 render(<ToDoList />);

 expect(await screen.findByText(/error/i)).toBeInTheDocument();
});

(فرض: کامپوننت شما پیام خطا نشون می‌ده — اگر نه، اول اون رو اضافه کنید!)


MSW در برابر mock fetch — کی کدوم؟

موقعیت پیشنهاد
یک تابع کوچک با یک fetch vi.fn کافیه (بخش ۶)
کامپوننت React + چند endpoint MSW
می‌خوای هم dev هم test یک mock داشته باشی MSW (حتی در browser هم کار می‌کنه)

خطاهای رایج

۱. فراموش کردن server.listen()

تست timeout می‌خوره یا fetch واقعی می‌زنه.

۲. handler برای URL اشتباه

/api/todos vs http://localhost:3000/api/todos — معمولاً path نسبی کافیه.

۳. فراموش کردن resetHandlers

تست قبلی handler عوض کرده — تست بعدی fail می‌شه.

۴. async و findBy

بعد از fetch همیشه await screen.findByText(...) — نه getBy فوری.


چک‌لیست Todo + MSW

  • MSW server در setupTests listen/close می‌شه؟
  • هر endpoint تست‌شده handler داره؟
  • userEvent با await؟
  • بعد از هر تست handlers reset می‌شن؟
  • loading state تست شده؟

ارتباط با بخش‌های قبل

بخش نقش در Todo
۵ Testing Library، render، screen
۶ اگر MSW نبود، vi.fn روی fetch
۷ MSW + پروژه کامل

TDD چیه؟ — یادآوری ساده

چرخه Red-Green-Refactor این است:

  1. قرمز — تست بنویس، fail ببین
  2. سبز — کمترین کد برای pass
  3. Refactor — تمیز کن، تست‌ها سبز بمونن
  4. تکرار برای feature بعدی
تست fail → کد minimal → refactor → feature بعدی

Todo List دقیقاً همین مسیر رو رفت: اول لیست، بعد add، بعد complete، بعد delete.


package.json — اسکریپت‌های تست

{
 "scripts": {
 "dev": "vite",
 "build": "vite build",
 "test": "vitest",
 "test:watch": "vitest --watch",
 "coverage": "vitest run --coverage"
 }
}

npm run test -- --watch موقع توسعه خیلی کمک می‌کنه — هر ذخیره، تست دوباره اجرا می‌شه.


Dashboard — دو API با MSW

مثال Dashboard رو می‌ده — کامپوننتی که دو تا fetch می‌زنه:

export function Dashboard() {
 const [user, setUser] = useState(null);
 const [notifications, setNotifications] = useState([]);

 useEffect(() => {
 fetch('/api/user').then((res) => res.json()).then(setUser);
 fetch('/api/notifications').then((res) => res.json()).then(setNotifications);
 }, []);

 if (!user || notifications.length === 0) return <div>Loading…</div>;

 return (
 <div>
 <h1>Welcome, {user.name}</h1>
 <ul>
 {notifications.map((n) => (
 <li key={n.id}>{n.message}</li>
 ))}
 </ul>
 </div>
 );
}

handlers

export const handlers = [
 rest.get('/api/user', (req, res, ctx) => {
 return res(ctx.json({ id: '123', name: 'Alice' }));
 }),
 rest.get('/api/notifications', (req, res, ctx) => {
 return res(ctx.json([
 { id: '1', message: 'Notification 1' },
 { id: '2', message: 'Notification 2' },
 ]));
 }),
];

تست

test('renders user and notifications', async () => {
 render(<Dashboard />);
 expect(screen.getByText(/loading/i)).toBeInTheDocument();

 expect(await screen.findByText(/welcome, alice/i)).toBeInTheDocument();
 const items = await screen.findAllByRole('listitem');
 expect(items).toHaveLength(2);
});

دو endpoint — یک setup MSW. بدون mock دستی در هر تست.


تست خطا — UserProfile

test('handles server error', async () => {
 server.use(
 rest.get('/api/user', (req, res, ctx) => {
 return res(ctx.status(500));
 }),
 );

 render(<UserProfile />);
 expect(await screen.findByText(/failed to load user/i)).toBeInTheDocument();
});

(کامپوننت باید پیام خطا نشون بده — اگر نداره، اول اون رو با TDD اضافه کن!)


چک کردن body درخواست POST

گاهی می‌خوای ببینی فرم درست submit شده:

test('submits form data correctly', async () => {
 let requestBody;

 server.use(
 rest.post('/api/submit', async (req, res, ctx) => {
 requestBody = await req.json();
 return res(ctx.status(200));
 }),
 );

 // render + submit form ...

 expect(requestBody).toEqual({ name: 'Alice', age: 30 });
});

MSW فقط جواب نمی‌ده — می‌تونی درخواست ورودی رو هم چک کنی.


onUnhandledRequest — سه حالت

مقدار رفتار
bypass پیش‌فرض — درخواست بدون handler رد می‌شه
warn warning در console
error تست fail — برای تست توصیه می‌شود
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

یعنی: اگر تستی به APIای زد که handler نداریم، سریع بفهمیم.


reset کردن state در handlers

برای handlers مرکزی با let todos:

export function resetTodos() {
 todos = [
 { id: 1, title: 'Buy groceries', completed: false },
 { id: 2, title: 'Walk the dog', completed: true },
 ];
}

// setupTests.js
beforeEach(() => resetTodos());

هر تست از لیست تمیز شروع می‌کنه.


فایل تست کامل ToDoList — ساختار

// src/tests/ToDoList.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ToDoList } from '../components/ToDoList';

const mockTodos = [
 { id: 1, title: 'Buy groceries', completed: false },
 { id: 2, title: 'Walk the dog', completed: true },
];

const server = setupServer(
 rest.get('/api/todos', (req, res, ctx) => res(ctx.json(mockTodos))),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('renders to-do items fetched from API', async () => { /* ... */ });
test('adds a new to-do item', async () => { /* ... */ });
test('marks a to-do item as completed', async () => { /* ... */ });
test('deletes a to-do item', async () => { /* ... */ });

چهار تست — چهار feature. هر کدوم مستقل با server.use برای override.


تمرین‌های اضافی

بعد از Todo پایه:

  1. فیلتر — all / active / completed
  2. ویرایش inline — rename task
  3. خطا — fail برای GET/POST/PUT/DELETE
  4. Optimistic update — UI قبل از جواب سرور
  5. localStorage — ذخیره محلی
  6. Accessibility — keyboard و screen reader
  7. جستجو — filter by text
  8. Bulk actions — complete/delete چندتایی

همه با همون الگو: اول تست، بعد کد.


MSW در browser (dev)

MSW فقط برای تست نیست — می‌تونی توی development هم از همون handlers استفاده کنی. یعنی frontend بدون backend واقعی کار کنه. setup جدا داره (msw/browser) — ولی ایده یکیه: یک جا mock، همه جا استفاده.


مقایسه سه لایه mock API

لایه ابزار کی؟
تابع vi.fn روی fetch unit کوچک
شبکه MSW کامپوننت React
مرورگر واقعی Playwright (بخش ۴) E2E

هر لایه جای خودش — همه رو با هم نزن!


پرسش‌های متداول MSW

MSW v1 و v2 فرق داره؟ این مثال‌ها از rest از msw استفاده می‌کنه (v1). در v2 API عوض شده — ولی ایده یکیه.

چرا تست timeout می‌خوره؟ معمولاً server.listen() فراموش شده یا URL handler اشتباهه.

چرا getBy fail می‌کنه ولی findBy کار می‌کنه؟ fetch async هست — باید صبر کنی UI آپدیت بشه.


جمع‌بندی بخش ۷

موضوع یاد گرفتیم
MSW mock در سطح شبکه
setup handlers + server + setupTests
TDD Red-Green-Refactor روی Todo
CRUD GET, POST, PUT, DELETE
خطا server.use با status 500
بهتر از fetch mock endpoint زیاد = MSW

تیزر بخش ۸ — پایان سری

آخرین مقاله: Snapshot testing، Code coverage، و CI با GitHub Actions — جمع‌بندی دوره.

موفق باشید!


Login با MSW — مثال دوم

علاوه بر /api/user، handler برای login هم می‌ده:

rest.post('/api/login', async (req, res, ctx) => {
 const { username } = await req.json();
 return res(ctx.status(200), ctx.json({ message: `Welcome, ${username}!` }));
}),

کامپوننت LoginForm — ساده

export function LoginForm() {
 const [username, setUsername] = useState('');
 const [message, setMessage] = useState('');

 async function handleSubmit(e) {
 e.preventDefault();
 const res = await fetch('/api/login', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ username }),
 });
 const data = await res.json();
 setMessage(data.message);
 }

 return (
 <form onSubmit={handleSubmit}>
 <input
 aria-label="Username"
 value={username}
 onChange={(e) => setUsername(e.target.value)}
 />
 <button type="submit">Login</button>
 {message && <p>{message}</p>}
 </form>
 );
}

تست

test('shows welcome message after login', async () => {
 const user = userEvent.setup();
 render(<LoginForm />);

 await user.type(screen.getByLabelText('Username'), 'Steve');
 await user.click(screen.getByRole('button', { name: /login/i }));

 expect(await screen.findByText('Welcome, Steve!')).toBeInTheDocument();
});

بدون mock دستی — handler مرکزی جواب می‌ده.


پیاده‌سازی error state در ToDoList

برای تست خطای API، اول کامپوننت باید error نشون بده:

const [error, setError] = useState(null);

function fetchTodos() {
 fetch('/api/todos')
 .then((res) => {
 if (!res.ok) throw new Error('Failed to fetch');
 return res.json();
 })
 .then((data) => {
 setTodos(data);
 setLoading(false);
 })
 .catch(() => {
 setError('Error loading todos');
 setLoading(false);
 });
}

if (error) return <div>{error}</div>;

حالا تست shows error when API fails pass می‌شه.


refactor: todoService.js

پیشنهاد می‌کنه وقتی fetch زیاد شد، API رو جدا کن:

// todoService.js
export function getTodos() {
 return fetch('/api/todos').then((res) => res.json());
}

export function createTodo(title) {
 return fetch('/api/todos', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ title }),
 });
}

export function updateTodo(todo) {
 return fetch(`/api/todos/${todo.id}`, {
 method: 'PUT',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(todo),
 });
}

export function deleteTodo(id) {
 return fetch(`/api/todos/${id}`, { method: 'DELETE' });
}

کامپوننت فقط service رو صدا می‌زنه — تست UI همچنان با MSW کار می‌کنه.


waitFor vs findBy

// findBy — ترجیح برای async
const item = await screen.findByText('Buy groceries');

// waitFor — وقتی assertion پیچیده‌تره
await waitFor(() => {
 expect(screen.getByText('Buy groceries')).toBeInTheDocument();
});

برای اکثر caseها findBy کافیه و خواناتره.


تست loading → loaded

test('shows loading then items', async () => {
 render(<ToDoList />);
 expect(screen.getByText(/loading/i)).toBeInTheDocument();
 await screen.findByText('Buy groceries');
 expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

دو state — loading و loaded — هر دو تست شدن.


GraphQL با MSW

MSW فقط REST نیست — GraphQL هم support می‌کنه. این مقاله روی REST تمرکز دارد؛ ولی ایده یکیه: handler مرکزی، override در تست.


debug وقتی MSW کار نمی‌کنه

  1. server.listen() صدا زده شده؟
  2. URL handler دقیقاً همون path که fetch می‌زنه؟
  3. onUnhandledRequest: 'error' فعاله؟ — پیام واضح‌تر
  4. resetHandlers در afterEach؟

Red-Green-Refactor — یک دور کامل

مرحله Todo List
Red تست list — fail (کامپوننت نیست)
Green ToDoList با fetch
Refactor extract fetchTodos
Red تست add — fail (input نیست)
Green input + button + POST
... complete، delete

هر feature یک چرخه — scope کوچک نگه دار.


پایان بخش ۷

MSW + TDD + Todo List = الگویی که برای پروژه واقعی پیشنهاد می‌شود. تمرین‌های اضافی (فیلتر، جستجو) رو خودتون با همین الگو بزنید.

تا بخش آخر — Snapshot و CI!