Skip to content

📅 NGÀY 56: MOCKING API CALLS VỚI MSW

Tóm tắt: Hôm nay chúng ta nâng cấp từ jest.fn() mock thủ công (Ngày 55) lên MSW (Mock Service Worker) — một công cụ mock ở tầng network, không phải tầng module. Bạn sẽ học cách setup MSW một lần, định nghĩa handlers, và tái sử dụng chúng cho mọi test. Kết quả là tests realistic hơn, dễ maintain hơn, và gần với behavior production nhất có thể mà không cần backend thật.


🎯 Mục tiêu học tập (5 phút)

  • [ ] Giải thích được MSW hoạt động ở tầng network, không phải tầng fetch — và tại sao điều đó quan trọng
  • [ ] Setup MSW server với setupServer, định nghĩa handlers cho GET/POST requests
  • [ ] Test đầy đủ loading state, success state, và error state của một component fetch data

🤔 Kiểm tra đầu vào (5 phút)

  1. Ở Ngày 55, bạn đã mock fetch bằng jest.fn(). Nếu component dùng axios thay vì fetch, mock đó có còn hoạt động không? Tại sao?
  2. waitForscreen.findBy* đều chờ async updates. Chúng khác nhau như thế nào?
  3. server.resetHandlers() được gọi trong afterEach — nếu bỏ dòng này, điều gì có thể xảy ra?

📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)

1.1 Vấn Đề Thực Tế

Sau ngày 55, code test của bạn trông như thế này:

tsx
// Ngày 55 approach — mock global.fetch
const mockFetch = jest.fn();
global.fetch = mockFetch;

beforeEach(() => mockFetch.mockReset());

it('should show users', async () => {
  mockFetch.mockResolvedValueOnce({
    ok: true,
    json: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
  });

  render(<UserList />);
  await screen.findByText('Alice');
});

Approach này có 3 vấn đề lớn:

1. Fragile và verbose: Mỗi test phải tự setup mock response, kể cả khi response giống nhau. Nếu component có 3 API calls, bạn phải mock 3 lần theo đúng thứ tự.

2. Không test được network layer: mockFetch chỉ mock JavaScript function, không mock network. Nếu component dùng axios, ky, hay XMLHttpRequest, mock này vô dụng.

3. Dễ sai silently: mockResolvedValueOnce theo thứ tự. Nếu thứ tự gọi thay đổi, mock sai response mà không có lỗi rõ ràng.

1.2 Giải Pháp: MSW hoạt động như thế nào?

MSW (Mock Service Worker) intercept request ở service worker level trong browser, hoặc ở Node.js http level trong test environment.

❌ Cách cũ (mock function):
Component → fetch() [MOCKED] → trả về fake response

            Bỏ qua toàn bộ network stack

✅ MSW (mock network):
Component → fetch() → XMLHttpRequest/node-fetch → [MSW INTERCEPTS] → trả về fake response

                                                  Giống như có server thật,
                                                  nhưng không cần server thật

Lợi ích thực tế:

  • Component dùng fetch, axios, ky hay bất cứ HTTP client nào — MSW đều bắt được
  • Định nghĩa handlers một lần, dùng cho mọi test trong cả project
  • Error scenarios rõ ràng: ctx.status(404) thay vì { ok: false, status: 404 }
  • Cùng handlers có thể dùng cho dev environment (browser Service Worker)

1.3 Mental Model

MSW Architecture trong Test Environment:

┌──────────────────────────────────────────────────┐
│                   Test File                       │
│                                                   │
│  beforeAll(() => server.listen())                 │
│  afterEach(() => server.resetHandlers())          │
│  afterAll(() => server.close())                   │
└─────────────────────┬────────────────────────────┘
                      │ render component

┌──────────────────────────────────────────────────┐
│               Component Under Test                │
│   useEffect → fetch('/api/users')                 │
└─────────────────────┬────────────────────────────┘
                      │ HTTP request

┌──────────────────────────────────────────────────┐
│              MSW Request Interceptor              │
│   Có handler cho GET /api/users?  →  YES         │
│   Execute handler → return mock response          │
└─────────────────────┬────────────────────────────┘
                      │ mock response

┌──────────────────────────────────────────────────┐
│              Component receives data              │
│   setState(data) → re-render → show UI            │
└──────────────────────────────────────────────────┘

Analogy: Mock fetch bằng jest.fn() giống như thay nhân viên bưu tá bằng người giả. MSW giống như lập một bưu cục giả tại địa chỉ đó — bất kỳ ai gửi thư đến địa chỉ này (dù dùng xe máy, xe đạp, hay đi bộ) đều nhận được phản hồi từ bưu cục giả đó.

1.4 Hiểu Lầm Phổ Biến

"MSW chậm hơn mock function" → MSW trong test environment (Node.js) không dùng Service Worker, không có network latency thật. Tốc độ tương đương mock function.

"Phải define handler cho mọi request" → Unhandled requests mặc định bị warn (không error). Bạn có thể dùng server.listen({ onUnhandledRequest: 'error' }) để strict hơn.

"server.resetHandlers() xóa tất cả handlers"resetHandlers() chỉ xóa handlers được thêm bằng server.use() trong test. Handlers gốc từ setupServer() không bị ảnh hưởng.

"MSW chỉ dùng được với RTL" → MSW độc lập với testing framework. Dùng được với Jest, Vitest, Playwright, hay thậm chí manual testing trong browser.


💻 PHẦN 2: LIVE CODING (45 phút)

Setup MSW (làm một lần cho cả project)

bash
npm install msw --save-dev
tsx
// src/mocks/handlers.ts
// Định nghĩa "default" mock responses — dùng cho happy path
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET /api/users — trả về danh sách users
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
      { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
    ]);
  }),

  // GET /api/users/:id — trả về user cụ thể
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;

    const users: Record<string, object> = {
      '1': {
        id: '1',
        name: 'Alice',
        email: 'alice@example.com',
        role: 'admin',
      },
      '2': { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
    };

    const user = users[id as string];
    if (!user) {
      return new HttpResponse(null, { status: 404 });
    }

    return HttpResponse.json(user);
  }),

  // POST /api/users — tạo user mới
  http.post('/api/users', async ({ request }) => {
    const body = (await request.json()) as { name: string; email: string };

    return HttpResponse.json(
      { id: '3', ...body, role: 'user' },
      { status: 201 },
    );
  }),
];
tsx
// src/mocks/server.ts
// Server dùng cho test environment (Node.js)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
tsx
// src/setupTests.ts (hoặc jest.setup.ts)
// Chạy trước TẤT CẢ tests
import { server } from './mocks/server';

// Bắt đầu intercept trước mọi test
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));

// Reset handlers về mặc định sau mỗi test
// (loại bỏ override được thêm bằng server.use() trong test)
afterEach(() => server.resetHandlers());

// Tắt interceptor sau khi tất cả tests chạy xong
afterAll(() => server.close());
json
// jest.config.js hoặc package.json
{
  "jest": {
    "setupFilesAfterFramework": ["<rootDir>/src/setupTests.ts"]
  }
}

Demo 1: Test Component Fetch Data ⭐

tsx
// components/UserList.tsx
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/users')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <div role='status'>Loading users...</div>;
  if (error) return <div role='alert'>Error: {error}</div>;
  if (users.length === 0) return <p>No users found</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <span>{user.name}</span>
          <span>{user.email}</span>
          {user.role === 'admin' && (
            <span data-testid={`admin-${user.id}`}>Admin</span>
          )}
        </li>
      ))}
    </ul>
  );
}
tsx
// components/UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { UserList } from './UserList';

// KHÔNG cần setup/teardown server ở đây!
// setupTests.ts đã lo rồi.

describe('UserList', () => {
  it('should show loading state initially', () => {
    render(<UserList />);
    // Loading hiển thị ngay lập tức — không cần await
    expect(screen.getByRole('status')).toHaveTextContent('Loading users...');
  });

  it('should render list of users', async () => {
    render(<UserList />);

    // Dùng findBy* — tự động chờ async update
    // Handler mặc định từ handlers.ts sẽ trả về Alice và Bob
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });

  it('should show admin badge for admin users', async () => {
    render(<UserList />);

    await screen.findByText('Alice');

    // Alice là admin, Bob không phải
    expect(screen.getByTestId('admin-1')).toBeInTheDocument();
    expect(screen.queryByTestId('admin-2')).not.toBeInTheDocument();
  });

  it('should show error when API fails', async () => {
    // Override handler cho test này
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 });
      }),
    );
    // Sau test này, server.resetHandlers() trong setupTests.ts
    // sẽ xóa override này — test tiếp theo vẫn dùng handler gốc

    render(<UserList />);

    expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 500');
  });

  it('should show empty state when no users', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json([]);
      }),
    );

    render(<UserList />);

    expect(await screen.findByText('No users found')).toBeInTheDocument();
  });
});

Demo 2: Test Component POST Data ⭐⭐

tsx
// components/CreateUserForm.tsx
interface CreateUserFormProps {
  onSuccess: (user: User) => void;
}

export function CreateUserForm({ onSuccess }: CreateUserFormProps) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

    try {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email }),
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const newUser = await res.json();
      onSuccess(newUser);
    } catch (err) {
      setError((err as Error).message);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
        />
      </label>
      <label>
        Email
        <input
          type='email'
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </label>
      {error && <p role='alert'>{error}</p>}
      <button
        type='submit'
        disabled={submitting}
      >
        {submitting ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}
tsx
// components/CreateUserForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { CreateUserForm } from './CreateUserForm';

describe('CreateUserForm', () => {
  const mockOnSuccess = jest.fn();

  beforeEach(() => {
    mockOnSuccess.mockReset();
  });

  it('should submit form and call onSuccess', async () => {
    const user = userEvent.setup();
    render(<CreateUserForm onSuccess={mockOnSuccess} />);

    await user.type(screen.getByLabelText('Name'), 'Charlie');
    await user.type(screen.getByLabelText('Email'), 'charlie@example.com');
    await user.click(screen.getByRole('button', { name: 'Create User' }));

    // Chờ button trở lại trạng thái bình thường (submitting = false)
    await screen.findByRole('button', { name: 'Create User' });

    // Handler POST mặc định từ handlers.ts đã được gọi
    expect(mockOnSuccess).toHaveBeenCalledWith({
      id: '3',
      name: 'Charlie',
      email: 'charlie@example.com',
      role: 'user',
    });
  });

  it('should show submitting state during request', async () => {
    // Override để delay response — test loading state
    server.use(
      http.post('/api/users', async () => {
        // Trả về Promise không resolve ngay
        await new Promise((resolve) => setTimeout(resolve, 100));
        return HttpResponse.json({ id: '3', name: 'Charlie' }, { status: 201 });
      }),
    );

    const user = userEvent.setup();
    render(<CreateUserForm onSuccess={mockOnSuccess} />);

    await user.type(screen.getByLabelText('Name'), 'Charlie');
    await user.type(screen.getByLabelText('Email'), 'charlie@example.com');
    await user.click(screen.getByRole('button', { name: 'Create User' }));

    // Ngay sau click — button đang submitting
    expect(screen.getByRole('button')).toHaveTextContent('Creating...');
    expect(screen.getByRole('button')).toBeDisabled();

    // Chờ hoàn thành
    await screen.findByRole('button', { name: 'Create User' });
  });

  it('should show error when server returns 422', async () => {
    server.use(
      http.post('/api/users', () => {
        return HttpResponse.json(
          { message: 'Email already exists' },
          { status: 422 },
        );
      }),
    );

    const user = userEvent.setup();
    render(<CreateUserForm onSuccess={mockOnSuccess} />);

    await user.type(screen.getByLabelText('Name'), 'Alice');
    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.click(screen.getByRole('button', { name: 'Create User' }));

    expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 422');
    expect(mockOnSuccess).not.toHaveBeenCalled();
  });
});

Demo 3: Network Error vs HTTP Error ⭐⭐⭐

tsx
// Phân biệt hai loại lỗi quan trọng:
// 1. HTTP Error (server trả về 4xx/5xx) — fetch() KHÔNG throw, phải check res.ok
// 2. Network Error (không kết nối được server) — fetch() THROW Error

// components/RobustFetcher.tsx
export function RobustFetcher({ url }: { url: string }) {
  const [state, setState] = useState<{
    data: unknown;
    loading: boolean;
    error: string | null;
    errorType: 'network' | 'http' | null;
  }>({ data: null, loading: true, error: null, errorType: null });

  useEffect(() => {
    let cancelled = false;

    fetch(url)
      .then(async (res) => {
        if (!res.ok) {
          // HTTP error — server phản hồi nhưng với error status
          const errorData = await res.json().catch(() => null);
          throw Object.assign(
            new Error(errorData?.message ?? `HTTP ${res.status}`),
            { type: 'http' as const },
          );
        }
        return res.json();
      })
      .then((data) => {
        if (!cancelled)
          setState({ data, loading: false, error: null, errorType: null });
      })
      .catch((err) => {
        if (!cancelled)
          setState({
            data: null,
            loading: false,
            error: err.message,
            errorType: err.type ?? 'network',
          });
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  if (state.loading) return <p role='status'>Loading...</p>;
  if (state.error) {
    return (
      <p
        role='alert'
        data-error-type={state.errorType}
      >
        {state.errorType === 'network'
          ? 'Cannot connect to server. Check your internet connection.'
          : `Error: ${state.error}`}
      </p>
    );
  }
  return <pre>{JSON.stringify(state.data, null, 2)}</pre>;
}
tsx
// components/RobustFetcher.test.tsx
import { http, HttpResponse, passthrough } from 'msw';

describe('RobustFetcher — error scenarios', () => {
  it('should handle HTTP 404 error', async () => {
    server.use(
      http.get('/api/data', () => {
        return HttpResponse.json(
          { message: 'Resource not found' },
          { status: 404 },
        );
      }),
    );

    render(<RobustFetcher url='/api/data' />);

    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent('Resource not found');
    expect(alert).toHaveAttribute('data-error-type', 'http');
  });

  it('should handle network error (no connection)', async () => {
    server.use(
      http.get('/api/data', () => {
        // MSW cách simulate network error
        return HttpResponse.error();
      }),
    );

    render(<RobustFetcher url='/api/data' />);

    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent('Cannot connect to server');
    expect(alert).toHaveAttribute('data-error-type', 'network');
  });

  it('should handle successful response', async () => {
    // Không cần override — dùng handler mặc định
    server.use(
      http.get('/api/data', () => {
        return HttpResponse.json({ result: 42 });
      }),
    );

    render(<RobustFetcher url='/api/data' />);

    await screen.findByText(/"result": 42/);
  });
});

🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)

⭐ Bài 1: Setup và Test Handler Cơ Bản (15 phút)

tsx
/**
 * 🎯 Mục tiêu: Viết handlers và test component GET cơ bản
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: jest.fn() để mock fetch
 *
 * Requirements:
 * 1. Tạo handler cho GET /api/products
 * 2. Test loading state
 * 3. Test hiển thị dữ liệu
 */

// Component đã cho sẵn:
interface Product {
  id: string;
  name: string;
  price: number;
}

export function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);

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

  if (loading) return <p role='status'>Loading...</p>;
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

// ❌ Cách SAI — vẫn dùng jest.fn():
const mockFetch = jest.fn();
global.fetch = mockFetch;
mockFetch.mockResolvedValue({
  json: () => [{ id: '1', name: 'Phone', price: 999 }],
});
// Không biết URL nào được gọi, không test được error cases dễ dàng

// ✅ Cách ĐÚNG — MSW handler:
// Xem nhiệm vụ bên dưới

// 🎯 NHIỆM VỤ:
// 1. Tạo handler cho GET /api/products trong handlers.ts
// 2. Viết test file cho ProductList
describe('ProductList', () => {
  it('should show loading state initially', () => {
    // TODO
  });

  it('should render products from API', async () => {
    // TODO: dùng findBy* để chờ
  });

  it('should format price correctly', async () => {
    // TODO
  });
});
💡 Solution
jsx
// src/mocks/handlers.ts — thêm vào file handlers
import { http, HttpResponse } from 'msw';

export const handlers = [
  // ... handlers khác ...

  http.get('/api/products', () => {
    return HttpResponse.json([
      { id: '1', name: 'Laptop', price: 1299 },
      { id: '2', name: 'Phone', price: 999 },
      { id: '3', name: 'Headphones', price: 199 },
    ]);
  }),
];
jsx
// components/ProductList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { ProductList } from './ProductList';

/**
 * ProductList — test component hiển thị danh sách sản phẩm
 * setupTests.ts đã handle server lifecycle
 */
describe('ProductList', () => {
  it('should show loading state initially', () => {
    render(<ProductList />);
    // Loading hiển thị synchronously — không cần await
    expect(screen.getByRole('status')).toHaveTextContent('Loading...');
  });

  it('should render products from API', async () => {
    render(<ProductList />);

    // findBy* tự động chờ element xuất hiện
    expect(await screen.findByText(/Laptop/)).toBeInTheDocument();
    expect(screen.getByText(/Phone/)).toBeInTheDocument();
    expect(screen.getByText(/Headphones/)).toBeInTheDocument();
  });

  it('should format price correctly', async () => {
    render(<ProductList />);

    await screen.findByText(/Laptop/);

    // Kiểm tra format "$1299"
    expect(screen.getByText(/\$1299/)).toBeInTheDocument();
  });

  it('should show empty list when no products', async () => {
    server.use(http.get('/api/products', () => HttpResponse.json([])));

    render(<ProductList />);

    // Loading biến mất, không có list item
    await screen.findByRole('list'); // <ul> vẫn render
    expect(screen.queryAllByRole('listitem')).toHaveLength(0);
  });
});

// Result: 4 tests passed

⭐⭐ Bài 2: Override Handler trong Test (25 phút)

tsx
/**
 * 🎯 Mục tiêu: Biết khi nào dùng handler mặc định vs server.use() override
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Bạn có component SearchUsers tìm kiếm user theo tên.
 *
 * 🤔 PHÂN TÍCH:
 * Approach A: Định nghĩa tất cả scenarios trong handlers.ts (default)
 * Pros: Centralized, reusable
 * Cons: Handlers phức tạp, khó maintain nhiều cases
 *
 * Approach B: Handler mặc định cho happy path,
 *             server.use() override cho error/edge cases
 * Pros: Handlers mặc định đơn giản, override chỉ khi cần
 * Cons: Logic test phân tán
 *
 * 💭 BẠN CHỌN GÌ? Hãy implement Approach B.
 */

// Component cần test:
export function SearchUsers() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const search = async (searchQuery) => {
    if (!searchQuery.trim()) return;

    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`/api/users?search=${searchQuery}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setUsers(await res.json());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <input
        placeholder='Search users...'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button onClick={() => search(query)}>Search</button>

      {loading && <p role='status'>Searching...</p>}
      {error && <p role='alert'>{error}</p>}
      <ul>
        {users.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </div>
  );
}

// 🎯 NHIỆM VỤ:
// 1. Thêm handler cho GET /api/users?search=* vào handlers.ts
// 2. Test happy path (dùng default handler)
// 3. Test "no results" (override handler)
// 4. Test error (override handler)
// 5. Test không search khi query rỗng
describe('SearchUsers', () => {
  // TODO: implement
});
💡 Solution
jsx
// handlers.ts — thêm search handler
http.get('/api/users', ({ request }) => {
  const url = new URL(request.url);
  const search = url.searchParams.get('search');

  // Nếu không có search query, trả về tất cả
  if (!search) {
    return HttpResponse.json([
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    ]);
  }

  // Happy path: tìm Alice
  const allUsers = [
    { id: '1', name: 'Alice', email: 'alice@example.com' },
    { id: '2', name: 'Bob', email: 'bob@example.com' },
  ];

  return HttpResponse.json(
    allUsers.filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
  );
}),
jsx
// SearchUsers.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { SearchUsers } from './SearchUsers';

/**
 * SearchUsers — test search functionality
 * Default handler: trả về kết quả theo tên (Alice, Bob)
 * Override handler: error cases và edge cases
 */
describe('SearchUsers', () => {
  it('should search and display results', async () => {
    const user = userEvent.setup();
    render(<SearchUsers />);

    await user.type(screen.getByPlaceholderText('Search users...'), 'Alice');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    // Default handler lọc theo tên
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.queryByText('Bob')).not.toBeInTheDocument();
  });

  it('should show loading state during search', async () => {
    server.use(
      http.get('/api/users', async () => {
        await new Promise((r) => setTimeout(r, 50)); // delay nhỏ
        return HttpResponse.json([{ id: '1', name: 'Alice' }]);
      }),
    );

    const user = userEvent.setup();
    render(<SearchUsers />);

    await user.type(screen.getByPlaceholderText('Search users...'), 'Alice');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    // Ngay sau click — loading
    expect(screen.getByRole('status')).toHaveTextContent('Searching...');

    // Sau khi xong — loading biến mất
    await screen.findByText('Alice');
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  it('should show empty state when no results found', async () => {
    server.use(http.get('/api/users', () => HttpResponse.json([])));

    const user = userEvent.setup();
    render(<SearchUsers />);

    await user.type(screen.getByPlaceholderText('Search users...'), 'xyz');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    // Chờ loading biến mất
    await screen.findByRole('list');
    expect(screen.queryAllByRole('listitem')).toHaveLength(0);
  });

  it('should show error when API fails', async () => {
    server.use(
      http.get('/api/users', () => new HttpResponse(null, { status: 503 })),
    );

    const user = userEvent.setup();
    render(<SearchUsers />);

    await user.type(screen.getByPlaceholderText('Search users...'), 'Alice');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503');
  });

  it('should NOT search when query is empty', async () => {
    const user = userEvent.setup();
    render(<SearchUsers />);

    // Click search với input rỗng
    await user.click(screen.getByRole('button', { name: 'Search' }));

    // Không có status, không có results, không có error
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
    expect(screen.queryAllByRole('listitem')).toHaveLength(0);
  });
});

// Result: 5 tests passed

⭐⭐⭐ Bài 3: Test CRUD Flow Đầy Đủ (40 phút)

tsx
/**
 * 🎯 Mục tiêu: Test toàn bộ CRUD flow với handlers cho mỗi method
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 User Story:
 * "Là admin, tôi muốn quản lý danh sách tags để
 * tôi có thể thêm, sửa, xóa tags từ dashboard."
 *
 * ✅ Acceptance Criteria:
 * - [ ] Hiển thị danh sách tags khi load
 * - [ ] Thêm tag mới và hiển thị ngay trong list
 * - [ ] Xóa tag và remove khỏi list
 * - [ ] Show error khi tên tag đã tồn tại (409 Conflict)
 *
 * 🚨 Edge Cases:
 * - Xóa tag đang được dùng → API trả 409
 * - Tên tag trống → không gọi API
 *
 * 📝 Implementation Checklist:
 * - [ ] Handlers cho GET, POST, DELETE /api/tags
 * - [ ] Test loading state
 * - [ ] Test add tag success flow
 * - [ ] Test delete tag flow
 * - [ ] Test duplicate tag error
 * - [ ] Test delete conflict error
 */

interface Tag {
  id: string;
  name: string;
  color: string;
}

export function TagManager() {
  const [tags, setTags] = useState<Tag[]>([]);
  const [loading, setLoading] = useState(true);
  const [newTagName, setNewTagName] = useState('');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/tags')
      .then((r) => r.json())
      .then((data) => {
        setTags(data);
        setLoading(false);
      });
  }, []);

  const addTag = async () => {
    if (!newTagName.trim()) return;
    setError(null);

    const res = await fetch('/api/tags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: newTagName.trim() }),
    });

    if (res.status === 409) {
      setError('Tag name already exists');
      return;
    }
    if (!res.ok) {
      setError(`Failed to create tag: HTTP ${res.status}`);
      return;
    }

    const created = await res.json();
    setTags((prev) => [...prev, created]);
    setNewTagName('');
  };

  const deleteTag = async (id: string) => {
    const res = await fetch(`/api/tags/${id}`, { method: 'DELETE' });

    if (res.status === 409) {
      setError('Cannot delete tag: it is currently in use');
      return;
    }
    if (!res.ok) return;

    setTags((prev) => prev.filter((t) => t.id !== id));
  };

  if (loading) return <p role='status'>Loading tags...</p>;

  return (
    <div>
      <div>
        <input
          value={newTagName}
          onChange={(e) => setNewTagName(e.target.value)}
          placeholder='New tag name'
          aria-label='New tag name'
        />
        <button onClick={addTag}>Add Tag</button>
      </div>
      {error && <p role='alert'>{error}</p>}
      <ul>
        {tags.map((tag) => (
          <li key={tag.id}>
            {tag.name}
            <button
              onClick={() => deleteTag(tag.id)}
              aria-label={`Delete ${tag.name}`}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 🎯 NHIỆM VỤ: Viết handlers và đầy đủ test suite
💡 Solution
jsx
// handlers.ts — thêm tag handlers
// State trong-memory cho tags (reset sau mỗi test qua resetHandlers)
let mockTags = [
  { id: '1', name: 'react', color: '#61dafb' },
  { id: '2', name: 'typescript', color: '#3178c6' },
];

http.get('/api/tags', () => {
  return HttpResponse.json(mockTags);
}),

http.post('/api/tags', async ({ request }) => {
  const { name } = await request.json();

  // Check duplicate
  if (mockTags.some(t => t.name === name)) {
    return new HttpResponse(null, { status: 409 });
  }

  const newTag = { id: String(Date.now()), name, color: '#888888' };
  mockTags.push(newTag);
  return HttpResponse.json(newTag, { status: 201 });
}),

http.delete('/api/tags/:id', ({ params }) => {
  const { id } = params;
  mockTags = mockTags.filter(t => t.id !== id);
  return new HttpResponse(null, { status: 204 });
}),
jsx
// TagManager.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { TagManager } from './TagManager';

/**
 * TagManager — CRUD flow tests
 * Handlers mặc định: GET/POST/DELETE /api/tags
 * Override cho error scenarios
 */
describe('TagManager', () => {
  it('should load and display tags', async () => {
    render(<TagManager />);

    expect(screen.getByRole('status')).toHaveTextContent('Loading tags...');

    expect(await screen.findByText('react')).toBeInTheDocument();
    expect(screen.getByText('typescript')).toBeInTheDocument();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  it('should add a new tag', async () => {
    const user = userEvent.setup();
    render(<TagManager />);

    await screen.findByText('react'); // Chờ load xong

    await user.type(screen.getByLabelText('New tag name'), 'javascript');
    await user.click(screen.getByRole('button', { name: 'Add Tag' }));

    // Tag mới xuất hiện trong list
    expect(await screen.findByText('javascript')).toBeInTheDocument();

    // Input được clear sau khi thêm thành công
    expect(screen.getByLabelText('New tag name')).toHaveValue('');
  });

  it('should show error when adding duplicate tag', async () => {
    server.use(
      http.post('/api/tags', () => new HttpResponse(null, { status: 409 })),
    );

    const user = userEvent.setup();
    render(<TagManager />);

    await screen.findByText('react');

    await user.type(screen.getByLabelText('New tag name'), 'react'); // tên đã tồn tại
    await user.click(screen.getByRole('button', { name: 'Add Tag' }));

    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Tag name already exists',
    );
  });

  it('should NOT call API when tag name is empty', async () => {
    const requestSpy = jest.fn();
    server.use(
      http.post('/api/tags', () => {
        requestSpy();
        return HttpResponse.json({}, { status: 201 });
      }),
    );

    const user = userEvent.setup();
    render(<TagManager />);

    await screen.findByText('react');

    // Click với input rỗng
    await user.click(screen.getByRole('button', { name: 'Add Tag' }));

    expect(requestSpy).not.toHaveBeenCalled();
  });

  it('should delete a tag', async () => {
    const user = userEvent.setup();
    render(<TagManager />);

    await screen.findByText('react');

    await user.click(screen.getByRole('button', { name: 'Delete react' }));

    // 'react' biến mất khỏi list
    await screen.findByText('typescript'); // wait for re-render
    expect(screen.queryByText('react')).not.toBeInTheDocument();
  });

  it('should show error when deleting tag in use', async () => {
    server.use(
      http.delete(
        '/api/tags/:id',
        () => new HttpResponse(null, { status: 409 }),
      ),
    );

    const user = userEvent.setup();
    render(<TagManager />);

    await screen.findByText('react');
    await user.click(screen.getByRole('button', { name: 'Delete react' }));

    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Cannot delete tag: it is currently in use',
    );

    // Tag vẫn còn trong list
    expect(screen.getByText('react')).toBeInTheDocument();
  });
});

// Result: 6 tests passed

⭐⭐⭐⭐ Bài 4: Stateful Handlers và Test Isolation (60 phút)

tsx
/**
 * 🎯 Mục tiêu: Xử lý stateful API mock đúng cách để tests không ảnh hưởng nhau
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Vấn đề: Khi handlers dùng state in-memory (như mockTags ở Bài 3),
 * state đó KHÔNG tự reset giữa các tests vì server.resetHandlers()
 * chỉ reset handler registration, không reset handler state.
 *
 * So sánh 3 approaches:
 *
 * Approach A: Handler stateless — mỗi test override handler với data riêng
 * Approach B: Reset state trong beforeEach bằng helper function
 * Approach C: Handler factory — createHandlers(initialData) tạo fresh handlers
 *
 * ADR:
 * - Context: Tests cần isolated state, không share qua handlers
 * - Decision: Approach B (reset function) cho đơn giản,
 *             Approach C (factory) cho complex scenarios
 * - Rationale: B đơn giản maintain, C flexible hơn nhưng verbose
 * - Consequences: Phải nhớ gọi reset trong beforeEach
 *
 * 💻 PHASE 2: Implement Approach B (30 phút)
 * Tạo NotificationManager component và test suite
 * với stateful API (notifications có thể mark as read)
 *
 * 🧪 PHASE 3: Verify Isolation (10 phút)
 * - [ ] Test 1: mark notification A as read
 * - [ ] Test 2: kiểm tra notification A vẫn unread (test isolation hoạt động)
 */

interface Notification {
  id: string;
  message: string;
  read: boolean;
}

export function NotificationCenter() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/notifications')
      .then((r) => r.json())
      .then((data) => {
        setNotifications(data);
        setLoading(false);
      });
  }, []);

  const markAsRead = async (id: string) => {
    await fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
    setNotifications((prev) =>
      prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
    );
  };

  if (loading) return <p role='status'>Loading...</p>;

  return (
    <ul>
      {notifications.map((n) => (
        <li
          key={n.id}
          data-read={String(n.read)}
        >
          {n.message}
          {!n.read && (
            <button onClick={() => markAsRead(n.id)}>Mark as read</button>
          )}
        </li>
      ))}
    </ul>
  );
}
💡 Solution
jsx
// handlers.ts — stateful notification handlers với reset function

// State in-memory — PHẢI reset trước mỗi test
let mockNotifications = [
  { id: '1', message: 'New comment on your post', read: false },
  { id: '2', message: 'Alice liked your photo', read: false },
  { id: '3', message: 'System maintenance tonight', read: true },
];

// Export reset function để dùng trong beforeEach
export function resetNotifications() {
  mockNotifications = [
    { id: '1', message: 'New comment on your post', read: false },
    { id: '2', message: 'Alice liked your photo', read: false },
    { id: '3', message: 'System maintenance tonight', read: true },
  ];
}

// Handler đọc từ mockNotifications (reactive với state)
http.get('/api/notifications', () => {
  return HttpResponse.json(mockNotifications);
}),

http.patch('/api/notifications/:id/read', ({ params }) => {
  const { id } = params;
  mockNotifications = mockNotifications.map(n =>
    n.id === id ? { ...n, read: true } : n
  );
  return new HttpResponse(null, { status: 204 });
}),
jsx
// NotificationCenter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { NotificationCenter } from './NotificationCenter';
import { resetNotifications } from '../mocks/handlers';

/**
 * NotificationCenter — stateful API tests
 * QUAN TRỌNG: Reset mock state trong beforeEach để test isolation
 */
describe('NotificationCenter', () => {
  // ⭐ Reset handler state trước mỗi test
  // resetHandlers() trong setupTests.ts reset handler REGISTRATION
  // nhưng KHÔNG reset handler's internal state (mockNotifications)
  beforeEach(() => {
    resetNotifications();
  });

  it('should display unread and read notifications', async () => {
    render(<NotificationCenter />);

    await screen.findByText('New comment on your post');

    // Unread notifications có button
    expect(
      screen.getAllByRole('button', { name: 'Mark as read' }),
    ).toHaveLength(2); // 2 unread

    // Read notification không có button
    const readItem = screen
      .getByText('System maintenance tonight')
      .closest('li');
    expect(readItem).toHaveAttribute('data-read', 'true');
  });

  it('should mark notification as read', async () => {
    const user = userEvent.setup();
    render(<NotificationCenter />);

    await screen.findByText('New comment on your post');

    const markReadButtons = screen.getAllByRole('button', {
      name: 'Mark as read',
    });
    await user.click(markReadButtons[0]); // Click first unread

    // Button biến mất sau khi mark as read
    expect(
      screen.getAllByRole('button', { name: 'Mark as read' }),
    ).toHaveLength(1); // Còn 1 unread
  });

  it('TEST ISOLATION: notification should still be unread in this test', async () => {
    // Nếu không có resetNotifications() trong beforeEach,
    // test trước sẽ ảnh hưởng và test này sẽ fail.
    render(<NotificationCenter />);

    await screen.findByText('New comment on your post');

    // Vẫn còn 2 unread — state đã được reset
    expect(
      screen.getAllByRole('button', { name: 'Mark as read' }),
    ).toHaveLength(2);
  });

  it('should handle mark-as-read API failure gracefully', async () => {
    server.use(
      http.patch(
        '/api/notifications/:id/read',
        () => new HttpResponse(null, { status: 500 }),
      ),
    );

    const user = userEvent.setup();
    render(<NotificationCenter />);

    await screen.findByText('New comment on your post');
    await user.click(
      screen.getAllByRole('button', { name: 'Mark as read' })[0],
    );

    // Dù API fail, component không crash (tùy implementation)
    // Nếu muốn revert optimistic update, sẽ rollback ở đây
    // Trong implementation hiện tại: UI update trước khi API confirm
    // Test này verify component không throw
    expect(screen.getByText('New comment on your post')).toBeInTheDocument();
  });
});

// Result: 4 tests passed, test isolation verified

⭐⭐⭐⭐⭐ Bài 5: Production Challenge — Test Dashboard với Multiple Endpoints (90 phút)

tsx
/**
 * 🎯 Mục tiêu: Test component fetch nhiều endpoints song song, production-grade
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * AdminDashboard load 3 endpoints song song:
 * - GET /api/stats — tổng số user, posts, revenue
 * - GET /api/recent-activity — 5 hoạt động gần nhất
 * - GET /api/alerts — cảnh báo hệ thống (có thể rỗng)
 *
 * Loading: hiển thị skeleton cho mỗi section riêng
 * Partial failure: section nào fail → show error trong section đó,
 *                  section khác vẫn hiển thị bình thường
 *
 * 🏗️ Technical Design Doc:
 * 1. Component Architecture: 3 sub-sections (Stats, Activity, Alerts)
 * 2. State Management: useReducer với actions per section
 * 3. API: Promise.all với per-section error handling
 * 4. Performance: parallel fetch, không waterfall
 *
 * ✅ Production Checklist:
 * - [ ] TypeScript types
 * - [ ] Partial failure handling
 * - [ ] Loading per section
 * - [ ] Empty state cho Alerts
 * - [ ] Test coverage cho mọi combination failure
 * - [ ] a11y: loading sections có role="status"
 */

// 🎯 NHIỆM VỤ: Implement AdminDashboard VÀ viết test suite đầy đủ
// Hint: Dùng useReducer để quản lý state phức tạp (đã học Ngày 26-29)
💡 Solution
jsx
// handlers.ts — dashboard handlers
http.get('/api/stats', () => {
  return HttpResponse.json({
    totalUsers: 1247,
    totalPosts: 8432,
    revenue: 52840,
  });
}),

http.get('/api/recent-activity', () => {
  return HttpResponse.json([
    { id: '1', type: 'signup', user: 'Charlie', timestamp: '2024-01-15T10:00:00Z' },
    { id: '2', type: 'post', user: 'Alice', timestamp: '2024-01-15T09:30:00Z' },
    { id: '3', type: 'purchase', user: 'Bob', timestamp: '2024-01-15T09:00:00Z' },
  ]);
}),

http.get('/api/alerts', () => {
  return HttpResponse.json([]); // Default: no alerts
}),
jsx
// AdminDashboard.tsx
const initialState = {
  stats: { data: null, loading: true, error: null },
  activity: { data: null, loading: true, error: null },
  alerts: { data: null, loading: true, error: null },
};

function dashboardReducer(state, action) {
  switch (action.type) {
    case 'SECTION_SUCCESS':
      return {
        ...state,
        [action.section]: { data: action.data, loading: false, error: null },
      };
    case 'SECTION_ERROR':
      return {
        ...state,
        [action.section]: { data: null, loading: false, error: action.error },
      };
    default:
      return state;
  }
}

export function AdminDashboard() {
  const [state, dispatch] = useReducer(dashboardReducer, initialState);

  useEffect(() => {
    const fetchSection = (url, section) =>
      fetch(url)
        .then((res) => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json();
        })
        .then((data) => dispatch({ type: 'SECTION_SUCCESS', section, data }))
        .catch((err) =>
          dispatch({ type: 'SECTION_ERROR', section, error: err.message }),
        );

    // Fetch tất cả song song — không waterfall
    fetchSection('/api/stats', 'stats');
    fetchSection('/api/recent-activity', 'activity');
    fetchSection('/api/alerts', 'alerts');
  }, []);

  return (
    <div>
      {/* Stats Section */}
      <section aria-label='Statistics'>
        {state.stats.loading && <p role='status'>Loading stats...</p>}
        {state.stats.error && (
          <p role='alert'>Stats error: {state.stats.error}</p>
        )}
        {state.stats.data && (
          <dl>
            <dt>Users</dt>
            <dd>{state.stats.data.totalUsers.toLocaleString()}</dd>
            <dt>Posts</dt>
            <dd>{state.stats.data.totalPosts.toLocaleString()}</dd>
            <dt>Revenue</dt>
            <dd>${state.stats.data.revenue.toLocaleString()}</dd>
          </dl>
        )}
      </section>

      {/* Activity Section */}
      <section aria-label='Recent Activity'>
        {state.activity.loading && <p role='status'>Loading activity...</p>}
        {state.activity.error && (
          <p role='alert'>Activity error: {state.activity.error}</p>
        )}
        {state.activity.data && (
          <ul>
            {state.activity.data.map((a) => (
              <li key={a.id}>
                {a.user} — {a.type}
              </li>
            ))}
          </ul>
        )}
      </section>

      {/* Alerts Section */}
      <section aria-label='System Alerts'>
        {state.alerts.loading && <p role='status'>Loading alerts...</p>}
        {state.alerts.error && (
          <p role='alert'>Alerts error: {state.alerts.error}</p>
        )}
        {state.alerts.data?.length === 0 && <p>No active alerts</p>}
        {state.alerts.data?.length > 0 && (
          <ul>
            {state.alerts.data.map((a) => (
              <li key={a.id}>{a.message}</li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}
jsx
// AdminDashboard.test.tsx
import { render, screen, within } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { AdminDashboard } from './AdminDashboard';

/**
 * AdminDashboard — test parallel fetch và partial failure
 */
describe('AdminDashboard', () => {
  it('should show loading state for all sections initially', () => {
    // Handlers không resolve ngay
    server.use(
      http.get('/api/stats', () => new Promise(() => {})),
      http.get('/api/recent-activity', () => new Promise(() => {})),
      http.get('/api/alerts', () => new Promise(() => {})),
    );

    render(<AdminDashboard />);

    const statusElements = screen.getAllByRole('status');
    expect(statusElements).toHaveLength(3); // 3 sections loading
  });

  it('should display all data when all APIs succeed', async () => {
    render(<AdminDashboard />);

    // Stats section
    expect(await screen.findByText('1,247')).toBeInTheDocument(); // totalUsers
    expect(screen.getByText('8,432')).toBeInTheDocument(); // totalPosts

    // Activity section
    expect(screen.getByText(/Charlie/)).toBeInTheDocument();
    expect(screen.getByText(/signup/)).toBeInTheDocument();

    // Alerts section — no alerts
    expect(screen.getByText('No active alerts')).toBeInTheDocument();
  });

  it('should show alerts when they exist', async () => {
    server.use(
      http.get('/api/alerts', () => {
        return HttpResponse.json([
          { id: 'a1', message: 'High CPU usage detected' },
          { id: 'a2', message: 'Database backup failed' },
        ]);
      }),
    );

    render(<AdminDashboard />);

    expect(
      await screen.findByText('High CPU usage detected'),
    ).toBeInTheDocument();
    expect(screen.getByText('Database backup failed')).toBeInTheDocument();
  });

  it('should show error in stats section but NOT affect other sections', async () => {
    // CHỈ stats fail — activity và alerts vẫn OK
    server.use(
      http.get('/api/stats', () => new HttpResponse(null, { status: 503 })),
    );

    render(<AdminDashboard />);

    // Stats section: error
    const statsSection = screen.getByRole('region', { name: 'Statistics' });
    expect(await within(statsSection).findByRole('alert')).toHaveTextContent(
      'Stats error: HTTP 503',
    );

    // Activity section: vẫn load thành công
    const activitySection = screen.getByRole('region', {
      name: 'Recent Activity',
    });
    expect(
      await within(activitySection).findByText(/Charlie/),
    ).toBeInTheDocument();

    // Alerts section: vẫn load thành công
    expect(await screen.findByText('No active alerts')).toBeInTheDocument();
  });

  it('should handle all sections failing', async () => {
    server.use(
      http.get('/api/stats', () => new HttpResponse(null, { status: 500 })),
      http.get(
        '/api/recent-activity',
        () => new HttpResponse(null, { status: 500 }),
      ),
      http.get('/api/alerts', () => new HttpResponse(null, { status: 500 })),
    );

    render(<AdminDashboard />);

    // Chờ tất cả loading biến mất
    const alerts = await screen.findAllByRole('alert');
    expect(alerts).toHaveLength(3); // 3 error messages

    // Không còn loading
    expect(screen.queryAllByRole('status')).toHaveLength(0);
  });

  it('should be accessible: loading sections have role=status', () => {
    server.use(
      http.get('/api/stats', () => new Promise(() => {})),
      http.get('/api/recent-activity', () => new Promise(() => {})),
      http.get('/api/alerts', () => new Promise(() => {})),
    );

    render(<AdminDashboard />);

    // Screen readers announce loading state
    expect(screen.getAllByRole('status')).toHaveLength(3);
  });
});

// Result: 6 tests passed
// Partial failure scenario là test quan trọng nhất — verify resilience

📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)

Bảng So Sánh: Jest Mock vs MSW

Tiêu chíjest.fn() mock fetchMSW
SetupKhông cần installnpm install msw
HTTP Client supportChỉ mock fetchMọi HTTP client
Handler definitionMỗi test tự defineDefine một lần, dùng lại
URL matchingPhải check mockFetch.mock.callsBuilt-in URL pattern matching
RealisticMock function JS, không giống networkIntercept actual network request
Reuse trong devKhôngCùng handlers dùng cho browser dev
ComplexityĐơn giản với 1-2 endpointsCần setup, overkill cho test đơn lẻ
Khi nào dùngQuick test, không có nhiều endpointsProject có API layer, cần realistic tests

Bảng: Chọn Handler Strategy

ScenarioStrategy
Happy path (hầu hết tests)Handlers mặc định trong handlers.ts
Error/edge case trong 1 testserver.use() override trong test
Nhiều tests cùng scenarioHandler riêng trong handlers.ts
Stateful APIState in-memory + reset function
Delay/timeout simulationawait new Promise(r => setTimeout(r, ms)) trong handler
Network errorHttpResponse.error()

Decision Tree: Jest Mock hay MSW?

Project của bạn có:

├── 1-2 tests cần mock API, component nhỏ, prototype?
│   └── jest.fn() mock — đơn giản, đủ dùng ✅

├── Test suite lớn, nhiều components fetch data?
│   └── MSW ✅
│       │
│       ├── Handlers simple, không có state?
│       │   └── Define trong handlers.ts, dùng mặc định ✅
│       │
│       └── Handlers có state (POST/PATCH/DELETE)?
│           └── State in-memory + resetXxx() function ✅
│               Gọi reset() trong beforeEach

└── Cần test timing/retry logic?
    └── server.use() với delay hoặc HttpResponse.error() ✅

🧪 PHẦN 5: DEBUG LAB (20 phút)

Bug 1: Handler Không Bắt Request

tsx
// ❌ Code bị lỗi — handler không bắt được request
// handlers.ts
http.get('https://api.myapp.com/users', () => {
  return HttpResponse.json([{ id: 1, name: 'Alice' }]);
});

// component dùng relative URL
fetch('/users'); // ← URL này không match handler!
Warning: [MSW] No handler found for "GET /users"
Test fails: data is null

Tại sao sai? Handler dùng absolute URL, component dùng relative URL. MSW match chính xác.

tsx
// ✅ Fix: Thống nhất URL format
// Option 1: Handler dùng relative URL (khuyến nghị cho test)
http.get('/users', () => {
  return HttpResponse.json([{ id: 1, name: 'Alice' }]);
});

// Option 2: Nếu component dùng absolute URL (ví dụ config baseURL)
http.get('https://api.myapp.com/users', () => {
  return HttpResponse.json([{ id: 1, name: 'Alice' }]);
});
// Và component cũng phải dùng đúng URL đó

Bug 2: State Leak Giữa Các Tests

tsx
// ❌ Code bị lỗi — tests ảnh hưởng nhau
let mockItems = [{ id: '1', name: 'Item A' }];

http.post('/api/items', async ({ request }) => {
  const body = await request.json();
  mockItems.push({ id: String(Date.now()), ...body });
  return HttpResponse.json(mockItems[mockItems.length - 1], { status: 201 });
});

// Test 1: Add item
it('test 1 - add item', async () => {
  // POST /api/items → mockItems = [Item A, Item B]
});

// Test 2: Check initial list — FAIL vì Item B vẫn còn!
it('test 2 - initial list has 1 item', async () => {
  render(<ItemList />);
  // GET /api/items → trả về mockItems = [Item A, Item B]
  // Test expect 1 item nhưng thấy 2 → FAIL
});

Tại sao sai? mockItems là module-level variable, không reset giữa tests.

tsx
// ✅ Fix: Export reset function và gọi trong beforeEach
const defaultItems = [{ id: '1', name: 'Item A' }];
let mockItems = [...defaultItems];

export function resetItems() {
  mockItems = [...defaultItems];
}

// Trong test file:
beforeEach(() => {
  resetItems();
});

Bug 3: server.use() Không Reset Sau Test

tsx
// ❌ Code bị lỗi — override handlers "rò rỉ" sang test tiếp theo
it('test 1 - error case', () => {
  server.use(
    http.get('/api/users', () => new HttpResponse(null, { status: 500 })),
  );
  render(<UserList />);
  // Kiểm tra error message
});

it('test 2 - success case', async () => {
  render(<UserList />);
  // Vẫn thấy error vì override từ test 1 chưa được reset!
  await screen.findByText('Alice'); // FAIL
});

Tại sao sai? server.use() thêm handler runtime, resetHandlers() trong afterEachsetupTests.ts phải có mặt.

tsx
// ✅ Fix option 1: Đảm bảo setupTests.ts có afterEach
afterEach(() => server.resetHandlers()); // ← Cần có dòng này

// ✅ Fix option 2: Reset thủ công nếu không muốn dùng global setupTests
it('test 1 - error case', () => {
  server.use(
    http.get('/api/users', () => new HttpResponse(null, { status: 500 })),
  );

  render(<UserList />);
  // test...

  server.resetHandlers(); // Reset sau test này nếu không có afterEach toàn cục
});

✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)

Knowledge Check

  • [ ] Tôi hiểu MSW intercept ở network layer, không phải JavaScript function level
  • [ ] Tôi biết cấu trúc file: handlers.ts, server.ts, setupTests.ts
  • [ ] Tôi biết server.use() để override handler trong 1 test
  • [ ] Tôi hiểu tại sao cần server.resetHandlers() trong afterEach
  • [ ] Tôi biết resetHandlers() không reset handler's internal state
  • [ ] Tôi biết HttpResponse.error() để simulate network error

Code Review Checklist

  • [ ] setupTests.ts có đủ beforeAll, afterEach, afterAll lifecycle
  • [ ] Handlers mặc định cover happy path
  • [ ] server.use() override dùng cho error/edge cases, không cho happy path
  • [ ] Stateful handlers có export reset function
  • [ ] Reset function được gọi trong beforeEach của test file
  • [ ] URL trong handler khớp với URL trong component (cả relative/absolute)

🏠 BÀI TẬP VỀ NHÀ

Bắt buộc (30 phút)

Chuyển đổi test suite từ Ngày 55 (useFetch test với jest.fn()) sang dùng MSW. So sánh số dòng code và độ readable. Bạn thấy approach nào dễ maintain hơn sau khi đã có experience với cả hai?

Nâng cao (60 phút)

Tạo một createMockServer(handlers) utility cho phép test file tạo một "local" mock server — thay vì dùng shared global server. Pattern này hữu ích khi handlers của module khác nhau conflict. Hint: setupServer() có thể gọi nhiều lần.


📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

Đọc thêm


🔗 KẾT NỐI KIẾN THỨC

Kiến thức nền

  • Ngày 19-20: Data fetching patterns — fetch, async/await, AbortController
  • Ngày 54: RTL basics — render, screen, queries, userEvent
  • Ngày 55: renderHook, wrapper, waitFor — test hooks với Context

Hướng tới

  • Ngày 57: Integration & E2E Testing — kết hợp MSW với Playwright để test full user flows

💡 SENIOR INSIGHTS

Cân Nhắc Production

Cùng handlers cho dev và test. MSW có thể chạy trong trình duyệt (Service Worker) — đây là killer feature. Team bạn có thể dùng cùng handlers để:

  • Mock API trong development khi backend chưa sẵn sàng
  • Demo features mà không cần backend
  • Onboard developer mới mà không cần setup backend
tsx
// main.tsx — chỉ enable MSW trong development
if (process.env.NODE_ENV === 'development') {
  const { worker } = await import('./mocks/browser');
  await worker.start({ onUnhandledRequest: 'bypass' });
}

onUnhandledRequest: 'error' cho strict testing. Trong CI, dùng 'error' thay vì 'warn' để test fail rõ ràng khi có request không có handler — giúp phát hiện API calls bị miss.

Câu Hỏi Phỏng Vấn

Junior: MSW là gì và tại sao dùng nó thay vì mock global.fetch?

Mid: Giải thích sự khác nhau giữa server.resetHandlers() và reset state của handler. Tại sao cần cả hai?

Senior: Bạn setup MSW như thế nào để handlers được dùng cả trong unit tests, integration tests, Playwright E2E tests, và development environment mà không duplicate code?

War Stories

Dự án cũ của tôi có 300+ tests, tất cả đều dùng jest.mock('../api/client') để mock HTTP client. Một ngày team quyết định đổi từ axios sang ky. Kết quả: phải update 80+ test files. Nếu dùng MSW từ đầu, không một test nào cần thay đổi — vì MSW mock ở network layer, không quan tâm bạn dùng HTTP client nào. Migration mất 3 ngày thay vì 30 phút.


🔮 Preview Ngày 57

Ngày mai (và là bài cuối của Phase Testing): Integration & E2E Testing Preview. Bạn sẽ học điểm khác nhau giữa unit tests (Ngày 54-56), integration tests, và E2E tests. Chúng ta sẽ xem Playwright làm được gì mà RTL + MSW không làm được — và khi nào nên đầu tư vào E2E tests. Cũng sẽ có testing strategy matrix để bạn biết cách phân bổ effort hợp lý cho từng loại test.

Personal tech knowledge base