Skip to content

📅 NGÀY 55: TESTING HOOKS & CONTEXT

Tóm tắt: Hôm nay chúng ta đi sâu vào kiểm thử hai thứ "khó nhìn thấy" nhất trong React — custom hooks và Context. Bạn sẽ học cách dùng renderHook để test logic hook độc lập, wrap Provider để test consumer, và xử lý async updates đúng cách. Đây là kỹ năng phân biệt developer viết test để "có coverage" và developer viết test để "có confidence".


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

  • [ ] Dùng renderHook để test custom hook một cách độc lập, không cần mount component
  • [ ] Viết wrapper function để cung cấp Context cho hooks và components trong test
  • [ ] Xử lý async state updates đúng cách với waitForact

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

  1. Nếu một custom hook dùng useState bên trong, bạn có thể render nó trực tiếp như một component không? Tại sao?
  2. Khi useContext trả về undefined, điều gì xảy ra và nguyên nhân thường gặp nhất là gì?
  3. Với RTL (ngày 54), bạn đã biết screen.findBy* khác screen.getBy* như thế nào?

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

1.1 Vấn Đề Thực Tế

Giả sử bạn đã viết hook useFetch sau 2 tuần refactor, và bây giờ cần đảm bảo nó:

  • Trả về { data: null, loading: true, error: null } ngay lúc đầu
  • Cập nhật data khi fetch thành công
  • Set error khi fetch thất bại
  • Reset state khi url thay đổi

Nếu test bằng cách mount một component "giả" như <div>{hook.data}</div>, bạn đang test cả component lẫn hook — nếu test fail, bạn không biết lỗi ở đâu.

renderHook giải quyết đúng vấn đề này: test hook như một đơn vị độc lập.

1.2 Giải Pháp

React Testing Library cung cấp hai công cụ chính:

Công cụDùng choImport
renderHookTest custom hooks@testing-library/react
actWrap state updates@testing-library/react
waitForChờ async updates@testing-library/react
wrapper optionCung cấp Contextoption của renderHook / render

1.3 Mental Model

renderHook(() => useMyHook(args))


  Tạo một "invisible component" trong môi trường test

      ├── result.current   → giá trị hook trả về tại thời điểm hiện tại
      ├── rerender(args)   → gọi lại hook với args mới (như khi props thay đổi)
      └── unmount()        → giả lập component bị unmount (test cleanup)


Để test hook cần Context:

renderHook(() => useMyHook(), {
  wrapper: ({ children }) => (
    <MyContext.Provider value={mockValue}>
      {children}
    </MyContext.Provider>
  )
})

Analogy: renderHook giống như bạn bỏ hook vào một "hộp kính" — bạn có thể quan sát mọi thứ bên trong mà không cần xây cả một ngôi nhà (component) xung quanh nó.

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

"Tôi cần act() cho mọi state update" → RTL đã wrap nhiều thứ tự động. Chỉ cần act() khi update xảy ra NGOÀI RTL event (ví dụ: callback từ setTimeout, Promise resolve thủ công).

"waitFor chỉ dùng cho fetch"waitFor dùng cho bất kỳ assertion nào cần chờ DOM/state cập nhật theo thời gian.

"Phải test implementation của hook" → Test behavior (output), không test internal state. Bạn không care hook dùng useState hay useReducer bên trong.

"result.current tự cập nhật"result.current là một snapshot tại thời điểm bạn đọc nó, nhưng bản thân object result là reactive — bạn luôn đọc giá trị mới nhất qua result.current.


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

Demo 1: renderHook Cơ Bản ⭐

tsx
// hooks/useCounter.ts
interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export function useCounter(initialValue = 0): UseCounterReturn {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((prev) => prev + 1), []);
  const decrement = useCallback(() => setCount((prev) => prev - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
tsx
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  // Test 1: Initial value
  it('should start with default value 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('should start with provided initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  // Test 2: Actions — wrap mutations trong act()
  it('should increment count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('should decrement count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  // Test 3: Reset về initialValue ban đầu
  it('should reset to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
    });
    expect(result.current.count).toBe(12);

    act(() => {
      result.current.reset();
    });
    expect(result.current.count).toBe(10); // Trở về initialValue, không phải 0
  });

  // Test 4: Rerender với args mới
  it('should update when initialValue prop changes', () => {
    const { result, rerender } = renderHook(({ init }) => useCounter(init), {
      initialProps: { init: 5 },
    });

    expect(result.current.count).toBe(5);

    // Giả lập parent component truyền prop mới
    rerender({ init: 20 });

    // count không đổi ngay lập tức vì reset chưa được gọi
    // Đây là behavior đúng của hook này
    expect(result.current.count).toBe(5);

    act(() => {
      result.current.reset();
    });

    // Sau khi reset, về initialValue mới
    expect(result.current.count).toBe(20);
  });
});

Demo 2: Testing Hook với Async (useFetch) ⭐⭐

tsx
// hooks/useFetch.ts
interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

export function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

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

    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        if (!cancelled) setState({ data, loading: false, error: null });
      })
      .catch((err) => {
        if (!cancelled)
          setState({ data: null, loading: false, error: err.message });
      });

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

  return state;
}
tsx
// hooks/useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

// Mock global fetch — KHÔNG dùng MSW ở ngày này
const mockFetch = jest.fn();
global.fetch = mockFetch;

describe('useFetch', () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  it('should return loading state initially', () => {
    // Fetch không resolve ngay — trả về Promise chưa settle
    mockFetch.mockReturnValue(new Promise(() => {}));

    const { result } = renderHook(() => useFetch('/api/users'));

    // Ngay lập tức sau render
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();
  });

  it('should return data on success', async () => {
    const mockData = [{ id: 1, name: 'Alice' }];

    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockData),
    });

    const { result } = renderHook(() => useFetch('/api/users'));

    // Chờ async update
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });

  it('should return error on failure', async () => {
    mockFetch.mockResolvedValue({
      ok: false,
      status: 404,
    });

    const { result } = renderHook(() => useFetch('/api/users'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBe('HTTP 404');
  });

  it('should reset state when url changes', async () => {
    const mockData1 = { id: 1 };
    const mockData2 = { id: 2 };

    mockFetch
      .mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve(mockData1),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve(mockData2),
      });

    const { result, rerender } = renderHook(({ url }) => useFetch(url), {
      initialProps: { url: '/api/users/1' },
    });

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual(mockData1);

    // Thay đổi URL — hook phải reset về loading
    rerender({ url: '/api/users/2' });

    // Ngay sau rerender: loading lại
    expect(result.current.loading).toBe(true);

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual(mockData2);
  });
});

Demo 3: Testing Context Consumer ⭐⭐⭐

tsx
// context/AuthContext.tsx
interface User {
  id: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthContextValue {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
  isAdmin: boolean;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = useCallback((user: User) => setUser(user), []);
  const logout = useCallback(() => setUser(null), []);
  const isAdmin = user?.role === 'admin';

  const value = useMemo(
    () => ({ user, login, logout, isAdmin }),
    [user, login, logout, isAdmin],
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth phải được dùng trong AuthProvider');
  }
  return context;
}
tsx
// context/AuthContext.test.tsx
import { renderHook, act, render, screen } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';

// ============================================================
// CÁCH 1: Tạo wrapper riêng — dùng cho nhiều test
// ============================================================
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <AuthProvider>{children}</AuthProvider>
);

describe('useAuth hook', () => {
  it('should throw error khi dùng ngoài Provider', () => {
    // Suppress console.error để test output sạch hơn
    const consoleSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => {});

    expect(() => {
      renderHook(() => useAuth());
    }).toThrow('useAuth phải được dùng trong AuthProvider');

    consoleSpy.mockRestore();
  });

  it('should return null user initially', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });

    expect(result.current.user).toBeNull();
    expect(result.current.isAdmin).toBe(false);
  });

  it('should login user', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    const mockUser: User = { id: '1', name: 'Alice', role: 'user' };

    act(() => {
      result.current.login(mockUser);
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.isAdmin).toBe(false);
  });

  it('should detect admin role', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    const adminUser: User = { id: '2', name: 'Bob', role: 'admin' };

    act(() => {
      result.current.login(adminUser);
    });

    expect(result.current.isAdmin).toBe(true);
  });

  it('should logout user', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });

    act(() => {
      result.current.login({ id: '1', name: 'Alice', role: 'user' });
    });
    expect(result.current.user).not.toBeNull();

    act(() => {
      result.current.logout();
    });
    expect(result.current.user).toBeNull();
  });
});

// ============================================================
// CÁCH 2: Test component consume Context — dùng render()
// ============================================================
function UserDisplay() {
  const { user, isAdmin } = useAuth();
  if (!user) return <p>Not logged in</p>;
  return (
    <div>
      <p>Hello, {user.name}</p>
      {isAdmin && <span data-testid='admin-badge'>Admin</span>}
    </div>
  );
}

describe('Component consuming AuthContext', () => {
  it('should show not logged in by default', () => {
    render(
      <AuthProvider>
        <UserDisplay />
      </AuthProvider>,
    );

    expect(screen.getByText('Not logged in')).toBeInTheDocument();
  });

  it('should show user name after login', () => {
    // ============================================================
    // CÁCH 3: Wrapper phức tạp với custom initial state
    // ============================================================
    function TestWrapper({ children }: { children: React.ReactNode }) {
      return (
        <AuthProvider>
          {/* Thêm LoginButton để trigger login trong test */}
          {children}
        </AuthProvider>
      );
    }

    // Giả lập component có nút login
    function TestApp() {
      const { login } = useAuth();
      return (
        <>
          <button
            onClick={() => login({ id: '1', name: 'Charlie', role: 'user' })}
          >
            Login
          </button>
          <UserDisplay />
        </>
      );
    }

    render(
      <AuthProvider>
        <TestApp />
      </AuthProvider>,
    );

    expect(screen.getByText('Not logged in')).toBeInTheDocument();

    userEvent.click(screen.getByRole('button', { name: 'Login' }));

    expect(screen.getByText('Hello, Charlie')).toBeInTheDocument();
    expect(screen.queryByTestId('admin-badge')).not.toBeInTheDocument();
  });
});

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

⭐ Bài 1: Test useToggle Hook (15 phút)

tsx
/**
 * 🎯 Mục tiêu: Dùng renderHook để test một custom hook đơn giản
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: useEffect, useContext (chưa cần)
 *
 * Requirements:
 * 1. Test initial value (default false)
 * 2. Test toggle function
 * 3. Test set với custom initial value
 */

// Hook cần test (đã cho sẵn)
export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  return { value, toggle, setTrue, setFalse };
}

// ❌ Cách SAI — test qua component, không cô lập được hook:
function BadToggleTest() {
  const { value, toggle } = useToggle();
  return <button onClick={toggle}>{value ? 'ON' : 'OFF'}</button>;
}

test('BAD: testing toggle through component', () => {
  render(<BadToggleTest />);
  // Test này thực ra đang test component + hook cùng lúc
  // Nếu fail, không biết lỗi ở hook hay component render
  fireEvent.click(screen.getByRole('button'));
  expect(screen.getByText('ON')).toBeInTheDocument();
});

// ✅ Cách ĐÚNG — test hook trực tiếp:
test('GOOD: testing hook with renderHook', () => {
  const { result } = renderHook(() => useToggle());
  expect(result.current.value).toBe(false);

  act(() => result.current.toggle());
  expect(result.current.value).toBe(true);
});

// 🎯 NHIỆM VỤ:
// Viết đầy đủ test suite cho useToggle với renderHook:
describe('useToggle', () => {
  it('should initialize with false by default', () => {
    // TODO
  });

  it('should initialize with provided value', () => {
    // TODO: test useToggle(true)
  });

  it('should toggle value', () => {
    // TODO: toggle từ false → true → false
  });

  it('should set to true with setTrue', () => {
    // TODO
  });

  it('should set to false with setFalse', () => {
    // TODO
  });
});
💡 Solution
jsx
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

/**
 * Test suite cho useToggle hook
 * Kiểm tra toàn bộ API của hook: initial value, toggle, setTrue, setFalse
 */
describe('useToggle', () => {
  it('should initialize with false by default', () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current.value).toBe(false);
  });

  it('should initialize with provided value', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current.value).toBe(true);
  });

  it('should toggle value from false to true', () => {
    const { result } = renderHook(() => useToggle());

    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(true);
  });

  it('should toggle value back to false', () => {
    const { result } = renderHook(() => useToggle());

    act(() => {
      result.current.toggle();
    });
    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(false);
  });

  it('should set to true with setTrue', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.setTrue();
    });

    expect(result.current.value).toBe(true);
  });

  it('should set to false with setFalse', () => {
    const { result } = renderHook(() => useToggle(true));

    act(() => {
      result.current.setFalse();
    });

    expect(result.current.value).toBe(false);
  });

  it('setTrue should be idempotent', () => {
    const { result } = renderHook(() => useToggle(true));

    act(() => {
      result.current.setTrue(); // Already true, should stay true
    });

    expect(result.current.value).toBe(true);
  });
});

// Result mẫu khi chạy: 7 tests passed

⭐⭐ Bài 2: Test Hook Phụ Thuộc Context (25 phút)

tsx
/**
 * 🎯 Mục tiêu: Test hook cần Context với wrapper pattern
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: useCart hook dùng CartContext để đọc/cập nhật giỏ hàng.
 *
 * Approach A: Tạo wrapper mới trong từng test
 * Pros: Flexible, không phụ thuộc lẫn nhau
 * Cons: Verbose, lặp code
 *
 * Approach B: Tạo một helper createWrapper() dùng chung
 * Pros: DRY, dễ maintain
 * Cons: Cần nhớ reset state giữa các test
 *
 * 💭 BẠN CHỌN GÌ? Hãy implement Approach B và giải thích lý do.
 */

// Context và Hook đã cho sẵn:
interface CartItem {
  id: string;
  name: string;
  quantity: number;
  price: number;
}
interface CartContextValue {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  total: number;
}

const CartContext = createContext<CartContextValue | undefined>(undefined);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);

  const addItem = useCallback((item: Omit<CartItem, 'quantity'>) => {
    setItems((prev) => {
      const existing = prev.find((i) => i.id === item.id);
      if (existing) {
        return prev.map((i) =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
        );
      }
      return [...prev, { ...item, quantity: 1 }];
    });
  }, []);

  const removeItem = useCallback((id: string) => {
    setItems((prev) => prev.filter((i) => i.id !== id));
  }, []);

  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  );

  return (
    <CartContext.Provider value={{ items, addItem, removeItem, total }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart(): CartContextValue {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used within CartProvider');
  return ctx;
}

// 🎯 NHIỆM VỤ: Viết test suite cho useCart
describe('useCart', () => {
  // TODO: Tạo wrapper helper
  // TODO: Test initial empty state
  // TODO: Test addItem
  // TODO: Test addItem với existing item (tăng quantity)
  // TODO: Test removeItem
  // TODO: Test total calculation
  // TODO: Test throw error khi dùng ngoài Provider
});
💡 Solution
jsx
import { renderHook, act } from '@testing-library/react';
import { CartProvider, useCart } from './CartContext';

/**
 * Helper tạo wrapper cho CartContext.
 * Tái sử dụng trong toàn bộ test file.
 * Vì CartProvider khởi tạo với state rỗng mỗi lần,
 * không cần lo việc state bị shared giữa các test.
 */
const wrapper = ({ children }) => <CartProvider>{children}</CartProvider>;

describe('useCart', () => {
  it('should throw when used outside CartProvider', () => {
    const consoleSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => {});
    expect(() => {
      renderHook(() => useCart());
    }).toThrow('useCart must be used within CartProvider');
    consoleSpy.mockRestore();
  });

  it('should start with empty cart', () => {
    const { result } = renderHook(() => useCart(), { wrapper });

    expect(result.current.items).toHaveLength(0);
    expect(result.current.total).toBe(0);
  });

  it('should add a new item', () => {
    const { result } = renderHook(() => useCart(), { wrapper });

    act(() => {
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 });
    });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0]).toEqual({
      id: 'p1',
      name: 'Phone',
      price: 999,
      quantity: 1,
    });
  });

  it('should increase quantity when adding existing item', () => {
    const { result } = renderHook(() => useCart(), { wrapper });

    act(() => {
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 });
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 });
    });

    // Vẫn chỉ 1 unique item, nhưng quantity = 2
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].quantity).toBe(2);
  });

  it('should remove an item', () => {
    const { result } = renderHook(() => useCart(), { wrapper });

    act(() => {
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 });
      result.current.addItem({ id: 'p2', name: 'Case', price: 29 });
    });

    act(() => {
      result.current.removeItem('p1');
    });

    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].id).toBe('p2');
  });

  it('should calculate total correctly', () => {
    const { result } = renderHook(() => useCart(), { wrapper });

    act(() => {
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 });
      result.current.addItem({ id: 'p2', name: 'Case', price: 29 });
      result.current.addItem({ id: 'p1', name: 'Phone', price: 999 }); // quantity 2
    });

    // 999 * 2 + 29 * 1 = 2027
    expect(result.current.total).toBe(2027);
  });
});

// Result: 6 tests passed

⭐⭐⭐ Bài 3: Test Async Hook với waitFor (40 phút)

tsx
/**
 * 🎯 Mục tiêu: Test async state lifecycle đầy đủ
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 User Story:
 * "Là developer, tôi muốn có hook useUserProfile(userId) để
 * fetch thông tin user, để tôi có thể hiển thị profile page."
 *
 * ✅ Acceptance Criteria:
 * - [ ] Initial state: { user: null, loading: true, error: null }
 * - [ ] Success state: { user: UserData, loading: false, error: null }
 * - [ ] Error state: { user: null, loading: false, error: string }
 * - [ ] Khi userId thay đổi: reset về loading và fetch lại
 * - [ ] Cleanup: không update state sau khi unmount
 *
 * 🚨 Edge Cases:
 * - userId là null/undefined → không fetch
 * - userId thay đổi nhanh → chỉ lấy kết quả của request cuối
 * - Component unmount trong khi đang fetch → không setState
 */

interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

// Hook cần implement VÀ test:
export function useUserProfile(userId: string | null) {
  // TODO: Implement hook với đầy đủ states
}

// Mock fetch:
const mockFetch = jest.fn();
global.fetch = mockFetch;

// 🎯 NHIỆM VỤ: Viết đầy đủ test theo Acceptance Criteria
describe('useUserProfile', () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  // TODO: Implement tất cả test cases
});
💡 Solution
jsx
import { renderHook, waitFor } from '@testing-library/react';

/**
 * useUserProfile - Fetch user profile by ID
 * Handles: loading state, success, error, cleanup, null userId
 */
export function useUserProfile(userId) {
  const [state, setState] = useState({
    user: null,
    loading: userId !== null, // Nếu không có userId, không loading
    error: null,
  });

  useEffect(() => {
    if (!userId) {
      setState({ user: null, loading: false, error: null });
      return;
    }

    let cancelled = false;

    setState({ user: null, loading: true, error: null });

    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((user) => {
        if (!cancelled) setState({ user, loading: false, error: null });
      })
      .catch((err) => {
        if (!cancelled)
          setState({ user: null, loading: false, error: err.message });
      });

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

  return state;
}

// =========================================================
// TESTS
// =========================================================
const mockFetch = jest.fn();
global.fetch = mockFetch;

const mockUser = {
  id: '1',
  name: 'Alice',
  email: 'alice@example.com',
  avatar: 'https://example.com/alice.png',
};

describe('useUserProfile', () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  it('should start with loading state when userId is provided', () => {
    mockFetch.mockReturnValue(new Promise(() => {})); // Never resolves

    const { result } = renderHook(() => useUserProfile('1'));

    expect(result.current).toEqual({
      user: null,
      loading: true,
      error: null,
    });
  });

  it('should not fetch when userId is null', () => {
    const { result } = renderHook(() => useUserProfile(null));

    expect(mockFetch).not.toHaveBeenCalled();
    expect(result.current.loading).toBe(false);
  });

  it('should return user data on success', async () => {
    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUser),
    });

    const { result } = renderHook(() => useUserProfile('1'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBeNull();
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
  });

  it('should return error on HTTP failure', async () => {
    mockFetch.mockResolvedValue({ ok: false, status: 404 });

    const { result } = renderHook(() => useUserProfile('1'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toBeNull();
    expect(result.current.error).toBe('HTTP 404');
  });

  it('should reset and refetch when userId changes', async () => {
    const user1 = { ...mockUser, id: '1', name: 'Alice' };
    const user2 = { ...mockUser, id: '2', name: 'Bob' };

    mockFetch
      .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user1) })
      .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(user2) });

    const { result, rerender } = renderHook(({ id }) => useUserProfile(id), {
      initialProps: { id: '1' },
    });

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.user.name).toBe('Alice');

    // Thay đổi userId
    rerender({ id: '2' });

    // Phải reset về loading
    expect(result.current.loading).toBe(true);

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.user.name).toBe('Bob');
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });

  it('should not update state after unmount', async () => {
    let resolvePromise;
    mockFetch.mockReturnValue(
      new Promise((resolve) => {
        resolvePromise = resolve;
      }),
    );

    const { result, unmount } = renderHook(() => useUserProfile('1'));

    // Unmount TRƯỚC khi fetch resolve
    unmount();

    // Resolve fetch sau khi đã unmount
    resolvePromise({ ok: true, json: () => Promise.resolve(mockUser) });

    // Không có lỗi "Can't perform a React state update on an unmounted component"
    // result.current vẫn là loading state vì không có update nào xảy ra
    expect(result.current.loading).toBe(true);
  });
});

// Result: 6 tests passed, 0 "state update on unmounted component" warnings

⭐⭐⭐⭐ Bài 4: Test Multi-Context App (60 phút)

tsx
/**
 * 🎯 Mục tiêu: Test component phụ thuộc nhiều Context
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Design (20 phút)
 * Bạn có component <CheckoutButton /> dùng:
 * - useCart() để lấy items và total
 * - useAuth() để kiểm tra user đã login chưa
 * - Nếu chưa login: show "Login to checkout"
 * - Nếu cart rỗng: show "Cart is empty", disabled
 * - Nếu có items và đã login: show "Checkout ($X.XX)"
 *
 * 🤔 Câu hỏi Architecture:
 * Approach A: Tạo một AllProviders wrapper dùng chung
 * Approach B: Tạo từng wrapper riêng và compose khi cần
 * Approach C: Tạo helper createWrapper(options) linh hoạt
 *
 * Chọn Approach nào? Viết ADR ngắn (5 dòng) rồi implement.
 *
 * 💻 PHASE 2: Implementation (30 phút)
 * Implement đủ tests cho CheckoutButton.
 *
 * 🧪 PHASE 3: Edge Cases (10 phút)
 * - [ ] Test khi cả hai context cùng thay đổi
 * - [ ] Test button có aria-disabled khi cart rỗng
 */

// Component cần test:
function CheckoutButton() {
  const { items, total } = useCart();
  const { user } = useAuth();

  if (!user) {
    return <button disabled>Login to checkout</button>;
  }

  if (items.length === 0) {
    return (
      <button
        disabled
        aria-disabled='true'
      >
        Cart is empty
      </button>
    );
  }

  const formattedTotal = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(total);

  return (
    <button onClick={() => console.log('checkout')}>
      Checkout ({formattedTotal})
    </button>
  );
}
💡 Solution
jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckoutButton } from './CheckoutButton';
import { CartProvider } from './CartContext';
import { AuthProvider } from './AuthContext';

/**
 * ADR: Chọn Approach C — createWrapper(options)
 * Context: Tests cần nhiều tổ hợp state khác nhau (login/not, empty/full cart)
 * Decision: Helper function nhận initial state
 * Rationale: Flexible hơn AllProviders, ít repetition hơn compose từng test
 * Consequences: Cần maintain helper khi thêm Context mới
 */

/**
 * Helper tạo wrapper với initial state tùy chỉnh.
 * Trả về wrapper component để dùng với render().
 */
function createWrapper({ user = null, cartItems = [] } = {}) {
  return function Wrapper({ children }) {
    return (
      <AuthProvider initialUser={user}>
        <CartProvider initialItems={cartItems}>{children}</CartProvider>
      </AuthProvider>
    );
  };
}

// NOTE: Để test này hoạt động, AuthProvider và CartProvider
// cần accept initialUser/initialItems props.
// Trong thực tế, có thể mock Context value trực tiếp (xem bên dưới).

// ============================================================
// Alternative: Mock Context value trực tiếp (không cần modify Provider)
// ============================================================
function renderWithContexts({ user = null, cartItems = [], total = 0 } = {}) {
  // Tạo mock values
  const authValue = {
    user,
    login: jest.fn(),
    logout: jest.fn(),
    isAdmin: user?.role === 'admin',
  };

  const cartValue = {
    items: cartItems,
    addItem: jest.fn(),
    removeItem: jest.fn(),
    total,
  };

  return render(
    <AuthContext.Provider value={authValue}>
      <CartContext.Provider value={cartValue}>
        <CheckoutButton />
      </CartContext.Provider>
    </AuthContext.Provider>,
  );
}

describe('CheckoutButton', () => {
  it('should show login button when user is not logged in', () => {
    renderWithContexts({ user: null });

    const button = screen.getByRole('button', { name: 'Login to checkout' });
    expect(button).toBeDisabled();
  });

  it('should show empty cart message when logged in but cart is empty', () => {
    renderWithContexts({
      user: { id: '1', name: 'Alice', role: 'user' },
      cartItems: [],
    });

    const button = screen.getByRole('button', { name: 'Cart is empty' });
    expect(button).toBeDisabled();
    expect(button).toHaveAttribute('aria-disabled', 'true');
  });

  it('should show checkout button with total when logged in and has items', () => {
    renderWithContexts({
      user: { id: '1', name: 'Alice', role: 'user' },
      cartItems: [{ id: 'p1', name: 'Phone', price: 999, quantity: 1 }],
      total: 999,
    });

    const button = screen.getByRole('button', { name: 'Checkout ($999.00)' });
    expect(button).not.toBeDisabled();
  });

  it('should format total as currency', () => {
    renderWithContexts({
      user: { id: '1', name: 'Alice', role: 'user' },
      cartItems: [{ id: 'p1', name: 'Item', price: 10.5, quantity: 2 }],
      total: 21,
    });

    expect(screen.getByText(/\$21\.00/)).toBeInTheDocument();
  });

  it('should be accessible: disabled button has aria-disabled', () => {
    renderWithContexts({
      user: { id: '1', name: 'Alice', role: 'user' },
      cartItems: [],
    });

    // Keyboard users cần biết button disabled vì cart rỗng
    expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
  });
});

// Result: 5 tests passed

⭐⭐⭐⭐⭐ Bài 5: Production Challenge — Test useFormField Hook (90 phút)

tsx
/**
 * 🎯 Mục tiêu: Build và test một reusable form field hook production-ready
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * Hook useFormField(config) quản lý state của một form field với:
 * - value: giá trị hiện tại
 * - error: lỗi validation (null nếu valid)
 * - touched: user đã interact chưa (blur/change)
 * - validate(): chạy validation và trả về isValid
 * - props: object spread được vào <input> (onChange, onBlur, value)
 *
 * 🏗️ Technical Design:
 * 1. State: value, error, touched (useState)
 * 2. Validation: sync function từ config, chạy khi touched=true
 * 3. props object: memoized với useMemo
 * 4. validate(): expose để parent form có thể trigger
 *
 * ✅ Production Checklist:
 * - [ ] TypeScript types đầy đủ
 * - [ ] Validation chỉ hiện error sau khi user đã touch field
 * - [ ] validate() return boolean để form có thể check
 * - [ ] props.onChange nhận cả SyntheticEvent lẫn raw value
 * - [ ] a11y: aria-invalid khi có error
 * - [ ] Test coverage > 90%
 */

interface FormFieldConfig<T = string> {
  initialValue?: T;
  validate?: (value: T) => string | null; // null = valid
}

interface FormFieldReturn<T = string> {
  value: T;
  error: string | null;
  touched: boolean;
  validate: () => boolean;
  props: {
    value: T;
    onChange: (e: React.ChangeEvent<HTMLInputElement> | T) => void;
    onBlur: () => void;
    'aria-invalid': boolean;
    'aria-describedby'?: string;
  };
}

// 🎯 NHIỆM VỤ:
// 1. Implement useFormField hook
// 2. Viết test suite đầy đủ
// 3. Test cả hook (với renderHook) và component (với render)
// 4. Đảm bảo edge cases được cover
💡 Solution
jsx
import { renderHook, act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// ============================================================
// IMPLEMENTATION
// ============================================================

/**
 * useFormField - Manages state and validation for a single form field
 * @param config.initialValue - Default field value
 * @param config.validate - Validation function, returns error message or null
 */
export function useFormField({ initialValue = '', validate } = {}) {
  const [value, setValue] = useState(initialValue);
  const [error, setError] = useState(null);
  const [touched, setTouched] = useState(false);

  const runValidation = useCallback(
    (currentValue) => {
      if (!validate) return null;
      return validate(currentValue);
    },
    [validate],
  );

  const handleChange = useCallback(
    (eventOrValue) => {
      const newValue =
        eventOrValue?.target !== undefined
          ? eventOrValue.target.value
          : eventOrValue;

      setValue(newValue);

      // Chỉ validate real-time nếu đã touched
      if (touched) {
        setError(runValidation(newValue));
      }
    },
    [touched, runValidation],
  );

  const handleBlur = useCallback(() => {
    setTouched(true);
    setError(runValidation(value));
  }, [value, runValidation]);

  const validate = useCallback(() => {
    setTouched(true);
    const validationError = runValidation(value);
    setError(validationError);
    return validationError === null;
  }, [value, runValidation]);

  const props = useMemo(
    () => ({
      value,
      onChange: handleChange,
      onBlur: handleBlur,
      'aria-invalid': error !== null && touched,
      ...(error && touched ? { 'aria-describedby': 'field-error' } : {}),
    }),
    [value, handleChange, handleBlur, error, touched],
  );

  return { value, error, touched, validate, props };
}

// ============================================================
// TESTS
// ============================================================
const emailValidator = (value) => {
  if (!value) return 'Email is required';
  if (!value.includes('@')) return 'Invalid email format';
  return null;
};

describe('useFormField', () => {
  describe('initial state', () => {
    it('should initialize with empty string by default', () => {
      const { result } = renderHook(() => useFormField());
      expect(result.current.value).toBe('');
      expect(result.current.error).toBeNull();
      expect(result.current.touched).toBe(false);
    });

    it('should initialize with provided value', () => {
      const { result } = renderHook(() =>
        useFormField({ initialValue: 'hello' }),
      );
      expect(result.current.value).toBe('hello');
    });
  });

  describe('onChange behavior', () => {
    it('should update value on change', () => {
      const { result } = renderHook(() => useFormField());

      act(() => {
        result.current.props.onChange({ target: { value: 'new value' } });
      });

      expect(result.current.value).toBe('new value');
    });

    it('should accept raw value (not just event)', () => {
      const { result } = renderHook(() => useFormField());

      act(() => {
        result.current.props.onChange('direct value');
      });

      expect(result.current.value).toBe('direct value');
    });

    it('should NOT show error before blur (not touched)', () => {
      const { result } = renderHook(() =>
        useFormField({ validate: emailValidator }),
      );

      act(() => {
        result.current.props.onChange({ target: { value: 'invalid' } });
      });

      // Chưa blur → chưa touched → không show error
      expect(result.current.error).toBeNull();
      expect(result.current.touched).toBe(false);
    });

    it('should validate real-time AFTER touched', () => {
      const { result } = renderHook(() =>
        useFormField({ validate: emailValidator }),
      );

      // Blur trước để set touched
      act(() => {
        result.current.props.onBlur();
      });

      // Sau khi touched, onChange phải validate
      act(() => {
        result.current.props.onChange({ target: { value: 'notanemail' } });
      });

      expect(result.current.error).toBe('Invalid email format');
    });
  });

  describe('onBlur behavior', () => {
    it('should set touched on blur', () => {
      const { result } = renderHook(() => useFormField());

      act(() => {
        result.current.props.onBlur();
      });

      expect(result.current.touched).toBe(true);
    });

    it('should validate and show error on blur', () => {
      const { result } = renderHook(() =>
        useFormField({ initialValue: '', validate: emailValidator }),
      );

      act(() => {
        result.current.props.onBlur();
      });

      expect(result.current.error).toBe('Email is required');
    });
  });

  describe('validate() method', () => {
    it('should return true when valid', () => {
      const { result } = renderHook(() =>
        useFormField({
          initialValue: 'test@example.com',
          validate: emailValidator,
        }),
      );

      let isValid;
      act(() => {
        isValid = result.current.validate();
      });

      expect(isValid).toBe(true);
      expect(result.current.error).toBeNull();
    });

    it('should return false and set error when invalid', () => {
      const { result } = renderHook(() =>
        useFormField({ initialValue: 'notanemail', validate: emailValidator }),
      );

      let isValid;
      act(() => {
        isValid = result.current.validate();
      });

      expect(isValid).toBe(false);
      expect(result.current.error).toBe('Invalid email format');
    });

    it('should set touched when validate() is called', () => {
      const { result } = renderHook(() => useFormField());

      act(() => {
        result.current.validate();
      });

      expect(result.current.touched).toBe(true);
    });
  });

  describe('accessibility props', () => {
    it('should have aria-invalid=false when no error or not touched', () => {
      const { result } = renderHook(() =>
        useFormField({ validate: emailValidator }),
      );

      expect(result.current.props['aria-invalid']).toBe(false);
    });

    it('should have aria-invalid=true when touched and has error', () => {
      const { result } = renderHook(() =>
        useFormField({ initialValue: 'bad', validate: emailValidator }),
      );

      act(() => {
        result.current.props.onBlur();
      });

      expect(result.current.props['aria-invalid']).toBe(true);
    });
  });

  describe('integration: component using hook', () => {
    function EmailInput() {
      const field = useFormField({ validate: emailValidator });
      return (
        <div>
          <label htmlFor='email'>Email</label>
          <input
            id='email'
            {...field.props}
          />
          {field.error && field.touched && (
            <p
              id='field-error'
              role='alert'
            >
              {field.error}
            </p>
          )}
        </div>
      );
    }

    it('should show error message after blur with invalid input', async () => {
      const user = userEvent.setup();
      render(<EmailInput />);

      const input = screen.getByLabelText('Email');
      await user.type(input, 'notanemail');
      await user.tab(); // trigger blur

      expect(screen.getByRole('alert')).toHaveTextContent(
        'Invalid email format',
      );
      expect(input).toHaveAttribute('aria-invalid', 'true');
    });

    it('should clear error when valid email is typed', async () => {
      const user = userEvent.setup();
      render(<EmailInput />);

      const input = screen.getByLabelText('Email');
      await user.type(input, 'bad');
      await user.tab(); // touch it, show error

      expect(screen.getByRole('alert')).toBeInTheDocument();

      await user.clear(input);
      await user.type(input, 'valid@example.com'); // now valid

      expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    });
  });
});

// Result: 14 tests passed, coverage ~95%

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

Bảng So Sánh: Cách Test Hook có Context

ApproachƯu điểmNhược điểmDùng khi
Real ProviderTest integration thật, ít mockState không controllable từ bên ngoàiProvider đơn giản, không có initial state phức tạp
Mock Context value trực tiếpFull control, setup nhanhKhông test Provider logicTest consumer component, không cần test Provider
createWrapper(options) helperFlexible, DRYCần maintain helperNhiều test cần tổ hợp state khác nhau
Custom renderWithXxx helperExplicit, self-documentingVerbose nếu có nhiều biếnTeam lớn, cần clarity hơn brevity

Decision Tree: Khi Nào Dùng Gì?

Bạn cần test gì?

├── Chỉ test LOGIC của hook (không cần DOM)?
│   └── renderHook() ✅

├── Test hook cần Context?
│   ├── Context phức tạp (nhiều initial state)?
│   │   └── createWrapper(options) helper ✅
│   └── Context đơn giản?
│       └── wrapper: ({ children }) => <Provider>{children}</Provider> ✅

├── Test component consume Context?
│   ├── Cần kiểm soát Context state trong test?
│   │   └── Mock Context.Provider value trực tiếp ✅
│   └── Muốn test full integration?
│       └── Render với real Providers ✅

└── Test async state updates?
    ├── Chờ DOM thay đổi?
    │   └── await screen.findBy*() ✅
    └── Chờ state thay đổi trong hook?
        └── await waitFor(() => expect(...)) ✅

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

Bug 1: act() Warning

tsx
// ❌ Code bị lỗi — console có warning "act(...) not wrapped"
it('should fetch user', async () => {
  mockFetch.mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ id: 1 }),
  });

  const { result } = renderHook(() => useFetch('/api/user'));

  // ⛔ Không có waitFor — assertion chạy trước khi state update
  expect(result.current.data).not.toBeNull();
});
Error: Warning: An update to useFetch inside a test was not wrapped in act(...)
Expected: not.toBeNull()
Received: null

Tại sao sai? Promise resolve bất đồng bộ, nhưng assertion chạy ngay lập tức. State chưa update.

tsx
// ✅ Fix: Dùng waitFor để chờ
it('should fetch user', async () => {
  mockFetch.mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ id: 1 }),
  });

  const { result } = renderHook(() => useFetch('/api/user'));

  await waitFor(() => {
    expect(result.current.data).not.toBeNull();
  });
});

Bug 2: Stale result.current

tsx
// ❌ Code bị lỗi — test luôn pass dù logic sai
it('should toggle value twice and return to false', () => {
  const { result } = renderHook(() => useToggle());

  // Lưu reference vào variable — ĐÂY LÀ LỖI
  const { toggle } = result.current;

  act(() => toggle());
  act(() => toggle());

  expect(result.current.value).toBe(false);
  // Test pass nhưng vì lý do sai: toggle() thứ 2 là stale closure,
  // không toggle lại như mong đợi
});

Tại sao sai? toggle được destructure ra ngoài result.current — nó là reference đến function của render đầu tiên, có thể bị stale.

tsx
// ✅ Fix: Luôn gọi qua result.current
it('should toggle value twice and return to false', () => {
  const { result } = renderHook(() => useToggle());

  act(() => result.current.toggle()); // ✅ Luôn đọc từ result.current
  act(() => result.current.toggle());

  expect(result.current.value).toBe(false);
});

Bug 3: Context không có trong test

tsx
// ❌ Code bị lỗi
it('should show user name', () => {
  render(<UserProfile userId='1' />);
  // ⛔ UserProfile dùng useAuth() nhưng không có AuthProvider!
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
Error: useAuth phải được dùng trong AuthProvider

Tại sao sai? render() không tự động cung cấp Context. Component throw error.

tsx
// ✅ Fix: Wrap với Provider hoặc mock Context
it('should show user name', () => {
  render(
    <AuthContext.Provider
      value={{
        user: { id: '1', name: 'Alice' },
        login: jest.fn(),
        logout: jest.fn(),
        isAdmin: false,
      }}
    >
      <UserProfile userId='1' />
    </AuthContext.Provider>,
  );

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

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

Knowledge Check

  • [ ] Tôi biết renderHook trả về gì và cách đọc result.current
  • [ ] Tôi hiểu tại sao phải wrap mutations trong act()
  • [ ] Tôi biết cách dùng waitFor cho async assertions
  • [ ] Tôi biết 3 cách cung cấp Context trong test và khi nào dùng cái nào
  • [ ] Tôi hiểu tại sao không nên destructure trực tiếp từ result.current trước act()

Code Review Checklist

  • [ ] Mỗi test chỉ test một behavior
  • [ ] Test names mô tả behavior, không phải implementation
  • [ ] Không có test nào phụ thuộc vào thứ tự chạy
  • [ ] Mock được reset trong beforeEach
  • [ ] Async tests đều có await waitFor(...) hoặc await screen.findBy*()
  • [ ] consoleSpy.mockRestore() được gọi sau khi mock console.error

🏠 BÀI TẬP VỀ NHÀ

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

Viết test suite cho useLocalStorage hook:

tsx
function useLocalStorage<T>(key: string, initialValue: T) {
  // Đọc/ghi localStorage, sync với state
}
// Hint: Mock localStorage bằng jest.spyOn(Storage.prototype, 'getItem')

Nâng cao (60 phút)

Xây dựng createContextTestHelper<T>(Context, Provider) — một utility function nhận Context và Provider, trả về:

  • renderWithContext(ui, value): render component với mock context value
  • renderHookWithContext(hook, value): render hook với mock context value
  • Dùng TypeScript generics để type-safe

📚 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 53: Testing Philosophy — test behavior, không test implementation
  • Ngày 54: RTL basics — render, screen, queries, userEvent
  • Ngày 24: Custom hooks — naming convention, extraction pattern
  • Ngày 36-37: Context API — createContext, Provider, useContext

Hướng tới

  • Ngày 56: MSW (Mock Service Worker) — mock API tầng network, không cần mock fetch thủ công
  • Ngày 57: Integration & E2E Testing — test toàn bộ flow người dùng

💡 SENIOR INSIGHTS

Cân Nhắc Production

Test utils riêng cho cả project. Thay vì lặp wrapper ở mọi file, tạo test-utils.tsx override render của RTL:

tsx
// test-utils.tsx — dùng cho toàn bộ project
import { render as rtlRender } from '@testing-library/react';

function AllProviders({ children }) {
  return (
    <AuthProvider>
      <CartProvider>
        <ThemeProvider>{children}</ThemeProvider>
      </CartProvider>
    </AuthProvider>
  );
}

export function render(ui, options) {
  return rtlRender(ui, { wrapper: AllProviders, ...options });
}

export * from '@testing-library/react'; // Re-export everything

Đừng test implementation. Nếu bạn refactor useToggle từ useState sang useReducer, test không nên break — vì behavior vẫn như cũ.

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

Junior: renderHook khác render như thế nào? Khi nào dùng cái nào?

Mid: Tại sao phải dùng act() khi gọi function từ hook trong test? Điều gì xảy ra nếu không có act()?

Senior: Bạn thiết kế test strategy như thế nào cho một Context có async actions (fetch trong Provider)? Có bao nhiêu unit tests vs integration tests là hợp lý?

War Stories

Một lần team tôi có bug: hook useNotifications không show error khi API fail. Unit tests đều green vì mọi test đều mock fetch thành công. Lesson learned: Luôn có ít nhất một test cho error path. Từ đó team có convention: mọi async hook phải có test case cho success, loading, và error — không có exception.


🔮 Preview Ngày 56

Ngày mai: MSW (Mock Service Worker) — thay vì mock global.fetch thủ công như hôm nay, chúng ta sẽ intercept request ở tầng network. Tests sẽ realistic hơn và dễ maintain hơn vì chỉ define mock một lần, dùng cho mọi test.

tsx
// Preview ngày mai:
const server = setupServer(
  rest.get('/api/users/:id', (req, res, ctx) => {
    return res(ctx.json({ id: req.params.id, name: 'Alice' }));
  }),
);

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

Personal tech knowledge base