Skip to content

📅 NGÀY 54: React Testing Library - Basics

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

  • [ ] Hiểu philosophy của React Testing Library và cách tiếp cận "test như user"
  • [ ] Sử dụng thành thạo render, screen và các query methods cơ bản
  • [ ] Viết được unit tests cho components với props, state, và events
  • [ ] Phân biệt và áp dụng đúng getBy/queryBy/findBy queries
  • [ ] Debug tests hiệu quả với screen.debug() và testing-playground

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

  1. Testing Philosophy (Ngày 53): Tại sao nên "test behavior, not implementation"?
  2. React Fundamentals: Component với props + state + events hoạt động như thế nào?
  3. DOM APIs: querySelector, textContent, getAttribute dùng để làm gì?

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

1.1 Vấn Đề Thực Tế

Scenario: Team lead review code của bạn

jsx
// ❌ Test này PASS nhưng component vẫn BROKEN cho users
test('counter works', () => {
  const wrapper = shallow(<Counter />);
  expect(wrapper.state('count')).toBe(0);
  wrapper.instance().increment();
  expect(wrapper.state('count')).toBe(1);
});

// Component thực tế:
const Counter = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);

  return (
    <div>
      <p>Count: {count}</p>
      {/* ❌ BUG: Button không gọi increment! */}
      <button>Increment</button>
    </div>
  );
};

Vấn đề: Test kiểm tra implementation (state, methods) chứ không kiểm tra behavior (user thấy gì, làm gì).

1.2 Giải Pháp: React Testing Library

Philosophy: "The more your tests resemble the way your software is used, the more confidence they can give you."

jsx
// ✅ Test này SẼ FAIL vì button không hoạt động
test('counter increments when button clicked', () => {
  render(<Counter />);

  // Tìm như user tìm: bằng text
  const button = screen.getByRole('button', { name: /increment/i });

  // Click như user click
  fireEvent.click(button);

  // Verify như user nhìn thấy
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Lợi ích:

  • Test phát hiện bug thật (button không click được)
  • Test không break khi refactor (đổi useState → useReducer vẫn OK)
  • Test document cách dùng component

1.3 Mental Model

USER PERSPECTIVE           RTL QUERIES              DOM
─────────────────         ─────────────          ──────
"Tôi thấy button          getByRole('button')    <button>
 có chữ Submit"           getByText('Submit')

"Tôi click vào đó"        fireEvent.click()      onclick handler

"Tôi thấy thông báo       getByText('Success')   <div>Success</div>
 Success"

FLOW:
1. render(<Component />)     → Mount component vào DOM
2. screen.getBy*()          → Tìm element như user tìm
3. fireEvent.*()            → Tương tác như user
4. expect().toBeInTheDocument() → Verify kết quả

Analogy: RTL như một robot test thủ công:

  • Không mở Chrome DevTools xem code
  • Chỉ dùng mắt (screen queries) và tay (fireEvent)
  • Kiểm tra những gì nhìn thấy trên màn hình

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

❌ Sai Lầm✅ Đúng💡 Tại Sao
Test state/props trực tiếpTest output (DOM)User không thấy state
Dùng className để queryDùng role/label/textUser không thấy class
Test implementation detailsTest behaviorRefactor-safe tests
Wrapper.find('button')screen.getByRole('button')User perspective
Snapshot mọi thứTargeted assertionsSnapshots dễ outdated

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

Demo 1: Pattern Cơ Bản ⭐

Setup Test Environment

jsx
// Counter.jsx
/**
 * Simple counter component for demo
 */
const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Counter App</h1>
      <p>Current count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
};

// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

// ❌ CÁCH SAI: Test implementation
test('❌ BAD: checking state directly', () => {
  const wrapper = mount(<Counter />);
  expect(wrapper.state('count')).toBe(0); // Fragile!
});

// ✅ CÁCH ĐÚNG: Test behavior
test('✅ GOOD: displays initial count', () => {
  render(<Counter />);

  // User sees this text
  expect(screen.getByText('Current count: 0')).toBeInTheDocument();
});

test('increments count when button clicked', () => {
  render(<Counter />);

  // Find button by accessible name
  const incrementButton = screen.getByRole('button', { name: /increment/i });

  // Simulate user click
  fireEvent.click(incrementButton);

  // Verify result
  expect(screen.getByText('Current count: 1')).toBeInTheDocument();
});

test('resets count to zero', () => {
  render(<Counter />);

  // Setup: increment first
  const incrementButton = screen.getByRole('button', { name: /increment/i });
  fireEvent.click(incrementButton);
  fireEvent.click(incrementButton);

  // Action: reset
  const resetButton = screen.getByRole('button', { name: /reset/i });
  fireEvent.click(resetButton);

  // Verify
  expect(screen.getByText('Current count: 0')).toBeInTheDocument();
});

Key Points:

  • render() mounts component
  • screen là global object để query DOM
  • fireEvent trigger events
  • Assertions kiểm tra DOM, không phải internal state

Demo 2: Query Methods Deep Dive ⭐⭐

Understanding getBy vs queryBy vs findBy

jsx
// LoginForm.jsx
const LoginForm = () => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [success, setSuccess] = useState(false);

  const handleSubmit = (e) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    setTimeout(() => {
      const email = e.target.email.value;
      if (email === 'test@example.com') {
        setSuccess(true);
        setLoading(false);
      } else {
        setError('Invalid credentials');
        setLoading(false);
      }
    }, 1000);
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Login</h2>

      <input
        type='email'
        name='email'
        placeholder='Enter email'
        aria-label='Email address'
      />

      <button
        type='submit'
        disabled={loading}
      >
        {loading ? 'Loading...' : 'Login'}
      </button>

      {error && <div role='alert'>{error}</div>}
      {success && <div>Welcome back!</div>}
    </form>
  );
};

// LoginForm.test.jsx
describe('LoginForm', () => {
  test('renders login form', () => {
    render(<LoginForm />);

    // getBy: throws if not found (for elements that MUST exist)
    expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
    expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
  });

  test('error message not shown initially', () => {
    render(<LoginForm />);

    // ❌ WRONG: getBy will throw error
    // expect(screen.getByRole('alert')).not.toBeInTheDocument();

    // ✅ CORRECT: queryBy returns null if not found
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  test('shows loading state during submission', () => {
    render(<LoginForm />);

    const emailInput = screen.getByLabelText(/email address/i);
    const submitButton = screen.getByRole('button', { name: /login/i });

    // Fill form
    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });

    // Submit
    fireEvent.click(submitButton);

    // Button text changes immediately
    expect(
      screen.getByRole('button', { name: /loading/i }),
    ).toBeInTheDocument();
  });

  test('shows error for invalid credentials', async () => {
    render(<LoginForm />);

    const emailInput = screen.getByLabelText(/email address/i);
    const submitButton = screen.getByRole('button', { name: /login/i });

    fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
    fireEvent.click(submitButton);

    // ✅ findBy for async elements (returns Promise)
    const errorMessage = await screen.findByRole('alert');
    expect(errorMessage).toHaveTextContent('Invalid credentials');
  });

  test('shows success message for valid credentials', async () => {
    render(<LoginForm />);

    const emailInput = screen.getByLabelText(/email address/i);
    const submitButton = screen.getByRole('button', { name: /login/i });

    fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
    fireEvent.click(submitButton);

    // Wait for async operation
    const successMessage = await screen.findByText(/welcome back/i);
    expect(successMessage).toBeInTheDocument();
  });
});

Query Methods Decision Tree:

Element tồn tại NGAY LẬP TỨC?
├─ YES → getBy*
│   └─ Throws if not found
│   └─ Best for: elements that must exist

├─ NO (might not exist) → queryBy*
│   └─ Returns null if not found
│   └─ Best for: asserting absence

└─ ASYNC (appears later) → findBy*
    └─ Returns Promise
    └─ Best for: async operations

Demo 3: Query Priority & Best Practices ⭐⭐⭐

jsx
// ProductCard.jsx
const ProductCard = ({ product, onAddToCart }) => {
  const [quantity, setQuantity] = useState(1);

  return (
    <article>
      <img
        src={product.image}
        alt={product.name}
      />

      <h3>{product.name}</h3>

      <p>{product.description}</p>

      <div>
        <span data-testid='price'>${product.price}</span>

        {product.inStock ? (
          <span className='badge-success'>In Stock</span>
        ) : (
          <span className='badge-danger'>Out of Stock</span>
        )}
      </div>

      <div>
        <label htmlFor={`qty-${product.id}`}>Quantity:</label>
        <input
          id={`qty-${product.id}`}
          type='number'
          value={quantity}
          onChange={(e) => setQuantity(Number(e.target.value))}
          min='1'
          max='10'
        />
      </div>

      <button
        onClick={() => onAddToCart(product.id, quantity)}
        disabled={!product.inStock}
        aria-label={`Add ${product.name} to cart`}
      >
        Add to Cart
      </button>
    </article>
  );
};

// ProductCard.test.jsx
describe('ProductCard', () => {
  const mockProduct = {
    id: '1',
    name: 'Awesome T-Shirt',
    description: 'Comfortable cotton t-shirt',
    price: 29.99,
    image: '/tshirt.jpg',
    inStock: true,
  };

  const mockAddToCart = jest.fn();

  // ❌ ANTI-PATTERN: Using test IDs everywhere
  test('❌ BAD: relying on test IDs', () => {
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    // This works but is not user-centric
    expect(screen.getByTestId('price')).toHaveTextContent('$29.99');
  });

  // ✅ BEST PRACTICE: Query priority
  test('✅ GOOD: using semantic queries', () => {
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    // 1. By Role (BEST - accessible)
    expect(
      screen.getByRole('heading', { name: /awesome t-shirt/i }),
    ).toBeInTheDocument();

    expect(
      screen.getByRole('button', { name: /add awesome t-shirt to cart/i }),
    ).toBeInTheDocument();

    // 2. By Label Text (for form inputs)
    expect(screen.getByLabelText(/quantity/i)).toHaveValue(1);

    // 3. By Alt Text (for images)
    expect(screen.getByAltText(/awesome t-shirt/i)).toHaveAttribute(
      'src',
      '/tshirt.jpg',
    );

    // 4. By Text (for content)
    expect(screen.getByText(/comfortable cotton/i)).toBeInTheDocument();

    // 5. By Test ID (LAST RESORT - only if nothing else works)
    // Should avoid when possible
  });

  test('shows in stock badge', () => {
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    // Text-based query
    expect(screen.getByText(/in stock/i)).toBeInTheDocument();
    expect(screen.queryByText(/out of stock/i)).not.toBeInTheDocument();
  });

  test('disables button when out of stock', () => {
    const outOfStockProduct = { ...mockProduct, inStock: false };

    render(
      <ProductCard
        product={outOfStockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    const button = screen.getByRole('button', { name: /add/i });
    expect(button).toBeDisabled();
    expect(screen.getByText(/out of stock/i)).toBeInTheDocument();
  });

  test('calls onAddToCart with correct arguments', () => {
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    // Change quantity
    const quantityInput = screen.getByLabelText(/quantity/i);
    fireEvent.change(quantityInput, { target: { value: '3' } });

    // Click add to cart
    const button = screen.getByRole('button', { name: /add/i });
    fireEvent.click(button);

    // Verify callback
    expect(mockAddToCart).toHaveBeenCalledTimes(1);
    expect(mockAddToCart).toHaveBeenCalledWith('1', 3);
  });

  test('handles multiple additions', () => {
    render(
      <ProductCard
        product={mockProduct}
        onAddToCart={mockAddToCart}
      />,
    );

    const button = screen.getByRole('button', { name: /add/i });

    fireEvent.click(button);
    fireEvent.click(button);

    expect(mockAddToCart).toHaveBeenCalledTimes(2);
  });
});

Query Priority (từ tốt nhất đến cuối cùng):

  1. getByRole - Accessible và semantic
  2. getByLabelText - Forms
  3. getByPlaceholderText - Forms (fallback)
  4. getByText - Content
  5. getByDisplayValue - Form current value
  6. getByAltText - Images
  7. getByTitle - Title attribute
  8. getByTestId - LAST RESORT

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

⭐ Bài 1: First Test - Warmup (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Viết test đầu tiên với RTL
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: findBy, queryBy (chỉ dùng getBy)
 *
 * Requirements:
 * 1. Test component renders với props
 * 2. Test button click thay đổi UI
 * 3. Dùng screen queries
 * 4. Assertions với toBeInTheDocument()
 *
 * 💡 Gợi ý: Bắt đầu với getByRole và getByText
 */

// Greeting.jsx - Component cần test
const Greeting = ({ name, onGreet }) => {
  const [greeted, setGreeted] = useState(false);

  return (
    <div>
      <h1>Hello, {name}!</h1>
      {greeted ? (
        <p>Nice to meet you!</p>
      ) : (
        <button
          onClick={() => {
            setGreeted(true);
            onGreet();
          }}
        >
          Greet Me
        </button>
      )}
    </div>
  );
};

// ❌ CÁCH SAI: Test state trực tiếp
test('❌ BAD: testing internal state', () => {
  const wrapper = shallow(
    <Greeting
      name='John'
      onGreet={() => {}}
    />,
  );
  expect(wrapper.state('greeted')).toBe(false); // Fragile!
  wrapper.find('button').simulate('click');
  expect(wrapper.state('greeted')).toBe(true);
});

// ✅ CÁCH ĐÚNG: Test behavior
// TODO: Implement this test
test('✅ GOOD: shows greeting message after click', () => {
  // 1. Setup mock
  const mockOnGreet = jest.fn();

  // 2. Render component
  // TODO: render(<Greeting name="John" onGreet={mockOnGreet} />);

  // 3. Verify initial render
  // TODO: Check heading contains "Hello, John!"
  // TODO: Check button exists with text "Greet Me"

  // 4. Simulate interaction
  // TODO: Click button

  // 5. Verify result
  // TODO: Check "Nice to meet you!" appears
  // TODO: Check button no longer exists
  // TODO: Verify callback was called
});
💡 Solution
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Greeting from './Greeting';

/**
 * Test suite for Greeting component
 */
describe('Greeting', () => {
  test('shows greeting message after click', () => {
    // Setup
    const mockOnGreet = jest.fn();

    // Render
    render(
      <Greeting
        name='John'
        onGreet={mockOnGreet}
      />,
    );

    // Verify initial state
    expect(screen.getByRole('heading')).toHaveTextContent('Hello, John!');
    expect(
      screen.getByRole('button', { name: /greet me/i }),
    ).toBeInTheDocument();

    // Interaction
    const button = screen.getByRole('button', { name: /greet me/i });
    fireEvent.click(button);

    // Verify result
    expect(screen.getByText(/nice to meet you/i)).toBeInTheDocument();
    expect(
      screen.queryByRole('button', { name: /greet me/i }),
    ).not.toBeInTheDocument();
    expect(mockOnGreet).toHaveBeenCalledTimes(1);
  });

  test('renders different names correctly', () => {
    render(
      <Greeting
        name='Alice'
        onGreet={() => {}}
      />,
    );
    expect(screen.getByText(/hello, alice!/i)).toBeInTheDocument();
  });
});

// Expected output:
// ✓ shows greeting message after click
// ✓ renders different names correctly

⭐⭐ Bài 2: Query Methods (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Phân biệt getBy vs queryBy vs findBy
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Component có conditional rendering và async operations
 *
 * 🤔 PHÂN TÍCH:
 * - getBy: Element tồn tại ngay lập tức
 * - queryBy: Element có thể không tồn tại (check absence)
 * - findBy: Element xuất hiện sau async operation
 *
 * 💭 KHI NÀO DÙNG GÌ?
 * Document quyết định của bạn trong test comments
 */

// StatusMessage.jsx
const StatusMessage = () => {
  const [status, setStatus] = useState('idle'); // idle | loading | success | error
  const [message, setMessage] = useState('');

  const fetchData = () => {
    setStatus('loading');
    setMessage('');

    setTimeout(() => {
      if (Math.random() > 0.5) {
        setStatus('success');
        setMessage('Data loaded successfully!');
      } else {
        setStatus('error');
        setMessage('Failed to load data');
      }
    }, 1000);
  };

  return (
    <div>
      <h2>Status Dashboard</h2>

      <button onClick={fetchData}>Fetch Data</button>

      {status === 'loading' && <div role='status'>Loading...</div>}

      {status === 'success' && (
        <div
          role='alert'
          style={{ color: 'green' }}
        >
          {message}
        </div>
      )}

      {status === 'error' && (
        <div
          role='alert'
          style={{ color: 'red' }}
        >
          {message}
        </div>
      )}
    </div>
  );
};

// StatusMessage.test.jsx
describe('StatusMessage', () => {
  test('initial render - no messages shown', () => {
    render(<StatusMessage />);

    // TODO: Verify heading exists (use getBy)
    // TODO: Verify button exists (use getBy)
    // TODO: Verify loading NOT shown (use queryBy)
    // TODO: Verify alert NOT shown (use queryBy)
  });

  test('shows loading state when fetching', () => {
    render(<StatusMessage />);

    // TODO: Click button
    // TODO: Verify loading appears (use getBy)
  });

  test('shows success message after fetch', async () => {
    // Mock Math.random to always succeed
    jest.spyOn(Math, 'random').mockReturnValue(0.9);

    render(<StatusMessage />);

    // TODO: Click button
    // TODO: Wait for success message (use findBy)
    // TODO: Verify loading is gone (use queryBy)

    Math.random.mockRestore();
  });

  test('shows error message on failure', async () => {
    jest.spyOn(Math, 'random').mockReturnValue(0.1);

    render(<StatusMessage />);

    // TODO: Click button
    // TODO: Wait for error message (use findBy)
    // TODO: Verify correct message text

    Math.random.mockRestore();
  });
});
💡 Solution
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import StatusMessage from './StatusMessage';

describe('StatusMessage', () => {
  test('initial render - no messages shown', () => {
    render(<StatusMessage />);

    // getBy: Elements that MUST exist
    expect(
      screen.getByRole('heading', { name: /status dashboard/i }),
    ).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: /fetch data/i }),
    ).toBeInTheDocument();

    // queryBy: Assert elements DON'T exist
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  test('shows loading state when fetching', () => {
    render(<StatusMessage />);

    const button = screen.getByRole('button', { name: /fetch data/i });
    fireEvent.click(button);

    // Loading appears immediately
    expect(screen.getByRole('status')).toHaveTextContent('Loading...');
  });

  test('shows success message after fetch', async () => {
    jest.spyOn(Math, 'random').mockReturnValue(0.9);

    render(<StatusMessage />);

    const button = screen.getByRole('button', { name: /fetch data/i });
    fireEvent.click(button);

    // findBy: Wait for async element
    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent('Data loaded successfully!');

    // Loading should be gone
    expect(screen.queryByRole('status')).not.toBeInTheDocument();

    Math.random.mockRestore();
  });

  test('shows error message on failure', async () => {
    jest.spyOn(Math, 'random').mockReturnValue(0.1);

    render(<StatusMessage />);

    fireEvent.click(screen.getByRole('button', { name: /fetch data/i }));

    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent('Failed to load data');

    Math.random.mockRestore();
  });
});

// Decision rationale:
// - getBy: Heading and button always exist → throw if missing
// - queryBy: Loading/alerts conditionally rendered → need null check
// - findBy: Success/error appear after 1000ms → need to wait

⭐⭐⭐ Bài 3: Todo App Testing (40 phút)

jsx
/**
 * 🎯 Mục tiêu: Test realistic component với multiple interactions
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là user, tôi muốn quản lý todos để track công việc"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Add new todo bằng form
 * - [ ] Toggle todo status (active/completed)
 * - [ ] Delete todo
 * - [ ] Filter todos (all/active/completed)
 * - [ ] Show todo count
 *
 * 🎨 Technical Constraints:
 * - Form validation: không cho submit empty todo
 * - UI updates sau mỗi action
 * - Filter không ảnh hưởng data
 *
 * 🚨 Edge Cases cần handle:
 * - Empty state message
 * - Toggle multiple todos
 * - Filter với 0 results
 *
 * 📝 Implementation Checklist:
 * - [ ] Test form submission
 * - [ ] Test toggle functionality
 * - [ ] Test delete functionality
 * - [ ] Test filtering
 * - [ ] Test empty states
 */

// TodoApp.jsx (already implemented)
const TodoApp = () => {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all'); // all | active | completed
  const [inputValue, setInputValue] = useState('');

  const addTodo = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      setTodos([
        ...todos,
        {
          id: Date.now(),
          text: inputValue,
          completed: false,
        },
      ]);
      setInputValue('');
    }
  };

  const toggleTodo = (id) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const filteredTodos = todos.filter((todo) => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });

  const activeCount = todos.filter((t) => !t.completed).length;

  return (
    <div>
      <h1>My Todos</h1>

      <form onSubmit={addTodo}>
        <input
          type='text'
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder='What needs to be done?'
          aria-label='New todo'
        />
        <button type='submit'>Add</button>
      </form>

      <div
        role='group'
        aria-label='Filter todos'
      >
        <button
          onClick={() => setFilter('all')}
          aria-pressed={filter === 'all'}
        >
          All
        </button>
        <button
          onClick={() => setFilter('active')}
          aria-pressed={filter === 'active'}
        >
          Active
        </button>
        <button
          onClick={() => setFilter('completed')}
          aria-pressed={filter === 'completed'}
        >
          Completed
        </button>
      </div>

      <p>{activeCount} items left</p>

      {filteredTodos.length === 0 ? (
        <p>No todos to show</p>
      ) : (
        <ul>
          {filteredTodos.map((todo) => (
            <li key={todo.id}>
              <input
                type='checkbox'
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
                aria-label={`Toggle ${todo.text}`}
              />
              <span
                style={{
                  textDecoration: todo.completed ? 'line-through' : 'none',
                }}
              >
                {todo.text}
              </span>
              <button
                onClick={() => deleteTodo(todo.id)}
                aria-label={`Delete ${todo.text}`}
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

// TodoApp.test.jsx - TODO: Implement these tests
describe('TodoApp', () => {
  test('renders empty state initially', () => {
    // TODO: Verify heading, input, filter buttons exist
    // TODO: Verify "No todos to show" message
    // TODO: Verify "0 items left"
  });

  test('adds new todo', () => {
    // TODO: Type into input
    // TODO: Submit form
    // TODO: Verify todo appears in list
    // TODO: Verify input cleared
    // TODO: Verify count updated
  });

  test('does not add empty todo', () => {
    // TODO: Submit form without typing
    // TODO: Verify no todo added
  });

  test('toggles todo completion', () => {
    // TODO: Add a todo
    // TODO: Click checkbox
    // TODO: Verify text has line-through
    // TODO: Verify active count decreased
  });

  test('deletes todo', () => {
    // TODO: Add a todo
    // TODO: Click delete button
    // TODO: Verify todo removed
    // TODO: Verify count updated
  });

  test('filters active todos', () => {
    // TODO: Add 3 todos
    // TODO: Complete 1 todo
    // TODO: Click "Active" filter
    // TODO: Verify only 2 active todos shown
  });

  test('filters completed todos', () => {
    // TODO: Add 3 todos
    // TODO: Complete 2 todos
    // TODO: Click "Completed" filter
    // TODO: Verify only 2 completed todos shown
  });

  test('shows all todos when "All" filter selected', () => {
    // TODO: Add 3 todos
    // TODO: Complete 1 todo
    // TODO: Click "Completed" then "All"
    // TODO: Verify all 3 todos shown
  });
});
💡 Solution
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import TodoApp from './TodoApp';

describe('TodoApp', () => {
  test('renders empty state initially', () => {
    render(<TodoApp />);

    expect(
      screen.getByRole('heading', { name: /my todos/i }),
    ).toBeInTheDocument();
    expect(screen.getByLabelText(/new todo/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();

    // Filter buttons
    expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /active/i })).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: /completed/i }),
    ).toBeInTheDocument();

    expect(screen.getByText(/no todos to show/i)).toBeInTheDocument();
    expect(screen.getByText(/0 items left/i)).toBeInTheDocument();
  });

  test('adds new todo', () => {
    render(<TodoApp />);

    const input = screen.getByLabelText(/new todo/i);
    const addButton = screen.getByRole('button', { name: /add/i });

    // Add todo
    fireEvent.change(input, { target: { value: 'Buy groceries' } });
    fireEvent.click(addButton);

    // Verify
    expect(screen.getByText(/buy groceries/i)).toBeInTheDocument();
    expect(input).toHaveValue(''); // Input cleared
    expect(screen.getByText(/1 items left/i)).toBeInTheDocument();
    expect(screen.queryByText(/no todos/i)).not.toBeInTheDocument();
  });

  test('does not add empty todo', () => {
    render(<TodoApp />);

    const addButton = screen.getByRole('button', { name: /add/i });

    // Try to add empty
    fireEvent.click(addButton);

    // Still shows empty state
    expect(screen.getByText(/no todos to show/i)).toBeInTheDocument();
    expect(screen.getByText(/0 items left/i)).toBeInTheDocument();
  });

  test('toggles todo completion', () => {
    render(<TodoApp />);

    // Add todo
    const input = screen.getByLabelText(/new todo/i);
    fireEvent.change(input, { target: { value: 'Learn testing' } });
    fireEvent.submit(input.closest('form'));

    // Toggle
    const checkbox = screen.getByLabelText(/toggle learn testing/i);
    fireEvent.click(checkbox);

    // Verify completed
    expect(checkbox).toBeChecked();
    expect(screen.getByText(/0 items left/i)).toBeInTheDocument();

    // Toggle back
    fireEvent.click(checkbox);
    expect(checkbox).not.toBeChecked();
    expect(screen.getByText(/1 items left/i)).toBeInTheDocument();
  });

  test('deletes todo', () => {
    render(<TodoApp />);

    // Add todo
    const input = screen.getByLabelText(/new todo/i);
    fireEvent.change(input, { target: { value: 'Delete me' } });
    fireEvent.submit(input.closest('form'));

    expect(screen.getByText(/delete me/i)).toBeInTheDocument();

    // Delete
    const deleteButton = screen.getByRole('button', {
      name: /delete delete me/i,
    });
    fireEvent.click(deleteButton);

    // Verify removed
    expect(screen.queryByText(/delete me/i)).not.toBeInTheDocument();
    expect(screen.getByText(/no todos to show/i)).toBeInTheDocument();
  });

  test('filters active todos', () => {
    render(<TodoApp />);

    // Add 3 todos
    const input = screen.getByLabelText(/new todo/i);

    fireEvent.change(input, { target: { value: 'Todo 1' } });
    fireEvent.submit(input.closest('form'));

    fireEvent.change(input, { target: { value: 'Todo 2' } });
    fireEvent.submit(input.closest('form'));

    fireEvent.change(input, { target: { value: 'Todo 3' } });
    fireEvent.submit(input.closest('form'));

    // Complete one
    const checkbox = screen.getByLabelText(/toggle todo 2/i);
    fireEvent.click(checkbox);

    // Filter active
    const activeButton = screen.getByRole('button', { name: /active/i });
    fireEvent.click(activeButton);

    // Verify
    expect(screen.getByText(/todo 1/i)).toBeInTheDocument();
    expect(screen.queryByText(/todo 2/i)).not.toBeInTheDocument();
    expect(screen.getByText(/todo 3/i)).toBeInTheDocument();
  });

  test('filters completed todos', () => {
    render(<TodoApp />);

    const input = screen.getByLabelText(/new todo/i);

    // Add 3 todos
    ['Task A', 'Task B', 'Task C'].forEach((task) => {
      fireEvent.change(input, { target: { value: task } });
      fireEvent.submit(input.closest('form'));
    });

    // Complete 2
    fireEvent.click(screen.getByLabelText(/toggle task a/i));
    fireEvent.click(screen.getByLabelText(/toggle task c/i));

    // Filter completed
    fireEvent.click(screen.getByRole('button', { name: /completed/i }));

    // Verify
    expect(screen.getByText(/task a/i)).toBeInTheDocument();
    expect(screen.queryByText(/task b/i)).not.toBeInTheDocument();
    expect(screen.getByText(/task c/i)).toBeInTheDocument();
  });

  test('shows all todos when "All" filter selected', () => {
    render(<TodoApp />);

    const input = screen.getByLabelText(/new todo/i);

    // Add and complete
    fireEvent.change(input, { target: { value: 'Item 1' } });
    fireEvent.submit(input.closest('form'));

    fireEvent.change(input, { target: { value: 'Item 2' } });
    fireEvent.submit(input.closest('form'));

    fireEvent.click(screen.getByLabelText(/toggle item 1/i));

    // Go to completed filter
    fireEvent.click(screen.getByRole('button', { name: /completed/i }));
    expect(screen.queryByText(/item 2/i)).not.toBeInTheDocument();

    // Back to all
    fireEvent.click(screen.getByRole('button', { name: /^all$/i }));

    // Both visible
    expect(screen.getByText(/item 1/i)).toBeInTheDocument();
    expect(screen.getByText(/item 2/i)).toBeInTheDocument();
  });
});

// All tests pass:
// ✓ renders empty state initially
// ✓ adds new todo
// ✓ does not add empty todo
// ✓ toggles todo completion
// ✓ deletes todo
// ✓ filters active todos
// ✓ filters completed todos
// ✓ shows all todos when "All" filter selected

⭐⭐⭐⭐ Bài 4: Testing Strategy (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Design comprehensive test strategy
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Nhiệm vụ:
 * 1. Phân tích component SearchableProductList
 * 2. Identify test scenarios (happy path, edge cases, errors)
 * 3. Quyết định query methods cho từng scenario
 * 4. Viết test plan
 *
 * ADR Template:
 * - Context: Component có search, filter, sort
 * - Decision: Test approach đã chọn
 * - Rationale: Tại sao chọn approach này
 * - Consequences: Trade-offs accepted
 * - Alternatives Considered: Các options khác
 *
 * 💻 PHASE 2: Implementation (30 phút)
 * Implement tests theo plan
 *
 * 🧪 PHASE 3: Review (10 phút)
 * - [ ] All scenarios covered?
 * - [ ] Using right query methods?
 * - [ ] Tests readable and maintainable?
 */

// SearchableProductList.jsx
const SearchableProductList = ({ products }) => {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortBy, setSortBy] = useState('name'); // name | price
  const [priceFilter, setPriceFilter] = useState('all'); // all | under50 | over50

  const filteredProducts = products
    .filter((p) => {
      const matchesSearch = p.name
        .toLowerCase()
        .includes(searchTerm.toLowerCase());

      if (priceFilter === 'under50') {
        return matchesSearch && p.price < 50;
      }
      if (priceFilter === 'over50') {
        return matchesSearch && p.price >= 50;
      }
      return matchesSearch;
    })
    .sort((a, b) => {
      if (sortBy === 'name') {
        return a.name.localeCompare(b.name);
      }
      return a.price - b.price;
    });

  return (
    <div>
      <h1>Product Catalog</h1>

      <input
        type='search'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder='Search products...'
        aria-label='Search products'
      />

      <div
        role='group'
        aria-label='Sort options'
      >
        <label>
          <input
            type='radio'
            name='sort'
            value='name'
            checked={sortBy === 'name'}
            onChange={() => setSortBy('name')}
          />
          Sort by Name
        </label>
        <label>
          <input
            type='radio'
            name='sort'
            value='price'
            checked={sortBy === 'price'}
            onChange={() => setSortBy('price')}
          />
          Sort by Price
        </label>
      </div>

      <select
        value={priceFilter}
        onChange={(e) => setPriceFilter(e.target.value)}
        aria-label='Filter by price'
      >
        <option value='all'>All Prices</option>
        <option value='under50'>Under $50</option>
        <option value='over50'>$50 and Above</option>
      </select>

      <p>{filteredProducts.length} products found</p>

      {filteredProducts.length === 0 ? (
        <p>No products match your criteria</p>
      ) : (
        <ul aria-label='Product list'>
          {filteredProducts.map((product) => (
            <li key={product.id}>
              <h3>{product.name}</h3>
              <p>${product.price}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

// TODO: Write comprehensive test suite
// Consider:
// - Initial render
// - Search functionality
// - Sorting (name vs price)
// - Price filtering
// - Combined search + filter + sort
// - Empty results
// - Edge cases (empty product list, special characters in search)
💡 Solution
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import SearchableProductList from './SearchableProductList';

/**
 * Test Strategy ADR
 *
 * Context:
 * - Component combines search, filter, and sort
 * - Multiple user interactions affect displayed data
 * - Need to test combinations of filters
 *
 * Decision:
 * - Test each feature independently first
 * - Then test combinations
 * - Use semantic queries (role, label)
 * - Setup helper for common data
 *
 * Rationale:
 * - Independent tests easier to debug
 * - Combinations test real user behavior
 * - Semantic queries = accessibility compliance
 *
 * Consequences:
 * - More tests but better coverage
 * - Longer test suite but maintainable
 *
 * Alternatives Considered:
 * - Snapshot testing: rejected (too brittle)
 * - Only testing combinations: rejected (hard to debug)
 */

describe('SearchableProductList', () => {
  const mockProducts = [
    { id: '1', name: 'Laptop', price: 999 },
    { id: '2', name: 'Mouse', price: 25 },
    { id: '3', name: 'Keyboard', price: 75 },
    { id: '4', name: 'Monitor', price: 300 },
    { id: '5', name: 'Headphones', price: 45 },
  ];

  describe('Initial Render', () => {
    test('displays all products', () => {
      render(<SearchableProductList products={mockProducts} />);

      expect(
        screen.getByRole('heading', { name: /product catalog/i }),
      ).toBeInTheDocument();
      expect(screen.getByText(/5 products found/i)).toBeInTheDocument();

      // All products visible
      mockProducts.forEach((product) => {
        expect(screen.getByText(product.name)).toBeInTheDocument();
      });
    });

    test('shows empty state for no products', () => {
      render(<SearchableProductList products={[]} />);

      expect(screen.getByText(/0 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/no products match/i)).toBeInTheDocument();
    });
  });

  describe('Search Functionality', () => {
    test('filters products by search term', () => {
      render(<SearchableProductList products={mockProducts} />);

      const searchInput = screen.getByLabelText(/search products/i);

      fireEvent.change(searchInput, { target: { value: 'mouse' } });

      expect(screen.getByText(/1 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/mouse/i)).toBeInTheDocument();
      expect(screen.queryByText(/laptop/i)).not.toBeInTheDocument();
    });

    test('search is case-insensitive', () => {
      render(<SearchableProductList products={mockProducts} />);

      const searchInput = screen.getByLabelText(/search products/i);

      fireEvent.change(searchInput, { target: { value: 'KEYBOARD' } });

      expect(screen.getByText(/keyboard/i)).toBeInTheDocument();
    });

    test('shows no results for non-matching search', () => {
      render(<SearchableProductList products={mockProducts} />);

      const searchInput = screen.getByLabelText(/search products/i);

      fireEvent.change(searchInput, { target: { value: 'xyz123' } });

      expect(screen.getByText(/0 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/no products match/i)).toBeInTheDocument();
    });
  });

  describe('Sorting', () => {
    test('sorts by name (default)', () => {
      render(<SearchableProductList products={mockProducts} />);

      const productList = screen.getByLabelText(/product list/i);
      const items = productList.querySelectorAll('h3');

      // Alphabetical order
      expect(items[0]).toHaveTextContent('Headphones');
      expect(items[1]).toHaveTextContent('Keyboard');
      expect(items[2]).toHaveTextContent('Laptop');
      expect(items[3]).toHaveTextContent('Monitor');
      expect(items[4]).toHaveTextContent('Mouse');
    });

    test('sorts by price', () => {
      render(<SearchableProductList products={mockProducts} />);

      const priceSortRadio = screen.getByLabelText(/sort by price/i);
      fireEvent.click(priceSortRadio);

      const productList = screen.getByLabelText(/product list/i);
      const items = productList.querySelectorAll('h3');

      // Price order (ascending)
      expect(items[0]).toHaveTextContent('Mouse'); // $25
      expect(items[1]).toHaveTextContent('Headphones'); // $45
      expect(items[2]).toHaveTextContent('Keyboard'); // $75
      expect(items[3]).toHaveTextContent('Monitor'); // $300
      expect(items[4]).toHaveTextContent('Laptop'); // $999
    });
  });

  describe('Price Filtering', () => {
    test('filters products under $50', () => {
      render(<SearchableProductList products={mockProducts} />);

      const priceFilter = screen.getByLabelText(/filter by price/i);
      fireEvent.change(priceFilter, { target: { value: 'under50' } });

      expect(screen.getByText(/2 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/mouse/i)).toBeInTheDocument();
      expect(screen.getByText(/headphones/i)).toBeInTheDocument();
      expect(screen.queryByText(/laptop/i)).not.toBeInTheDocument();
    });

    test('filters products $50 and above', () => {
      render(<SearchableProductList products={mockProducts} />);

      const priceFilter = screen.getByLabelText(/filter by price/i);
      fireEvent.change(priceFilter, { target: { value: 'over50' } });

      expect(screen.getByText(/3 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/keyboard/i)).toBeInTheDocument();
      expect(screen.queryByText(/mouse/i)).not.toBeInTheDocument();
    });
  });

  describe('Combined Filters', () => {
    test('applies search + price filter', () => {
      render(<SearchableProductList products={mockProducts} />);

      // Search for 'o'
      const searchInput = screen.getByLabelText(/search products/i);
      fireEvent.change(searchInput, { target: { value: 'o' } });

      // Filter under $50
      const priceFilter = screen.getByLabelText(/filter by price/i);
      fireEvent.change(priceFilter, { target: { value: 'under50' } });

      // Should show: Mouse, Headphones (both have 'o' and under $50)
      expect(screen.getByText(/2 products found/i)).toBeInTheDocument();
      expect(screen.getByText(/mouse/i)).toBeInTheDocument();
      expect(screen.getByText(/headphones/i)).toBeInTheDocument();
    });

    test('applies all filters: search + price + sort', () => {
      render(<SearchableProductList products={mockProducts} />);

      // Search 'e'
      fireEvent.change(screen.getByLabelText(/search products/i), {
        target: { value: 'e' },
      });

      // Price filter: over $50
      fireEvent.change(screen.getByLabelText(/filter by price/i), {
        target: { value: 'over50' },
      });

      // Sort by price
      fireEvent.click(screen.getByLabelText(/sort by price/i));

      // Should show: Keyboard ($75), Monitor ($300) sorted by price
      const items = screen.getAllByRole('heading', { level: 3 });
      expect(items).toHaveLength(2);
      expect(items[0]).toHaveTextContent('Keyboard');
      expect(items[1]).toHaveTextContent('Monitor');
    });
  });

  describe('Edge Cases', () => {
    test('handles clearing search', () => {
      render(<SearchableProductList products={mockProducts} />);

      const searchInput = screen.getByLabelText(/search products/i);

      // Search
      fireEvent.change(searchInput, { target: { value: 'laptop' } });
      expect(screen.getByText(/1 products found/i)).toBeInTheDocument();

      // Clear
      fireEvent.change(searchInput, { target: { value: '' } });
      expect(screen.getByText(/5 products found/i)).toBeInTheDocument();
    });

    test('handles switching between filters', () => {
      render(<SearchableProductList products={mockProducts} />);

      const priceFilter = screen.getByLabelText(/filter by price/i);

      // Under $50
      fireEvent.change(priceFilter, { target: { value: 'under50' } });
      expect(screen.getByText(/2 products found/i)).toBeInTheDocument();

      // Over $50
      fireEvent.change(priceFilter, { target: { value: 'over50' } });
      expect(screen.getByText(/3 products found/i)).toBeInTheDocument();

      // All
      fireEvent.change(priceFilter, { target: { value: 'all' } });
      expect(screen.getByText(/5 products found/i)).toBeInTheDocument();
    });
  });
});

// Test Results:
// ✓ Initial Render (2 tests)
// ✓ Search Functionality (3 tests)
// ✓ Sorting (2 tests)
// ✓ Price Filtering (2 tests)
// ✓ Combined Filters (2 tests)
// ✓ Edge Cases (2 tests)
//
// Total: 13 tests, all passing
// Coverage: Comprehensive feature coverage with combinations

⭐⭐⭐⭐⭐ Bài 5: Production-Ready Form Testing (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Test production-grade form với validation
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * - Multi-field registration form
 * - Real-time validation
 * - Async email check
 * - Accessible error messages
 * - Loading states
 *
 * 🏗️ Technical Design Doc:
 * 1. Component Architecture: Single form component
 * 2. State Management Strategy: useState for each field + errors
 * 3. Validation: Client-side + async server check
 * 4. Error Handling Strategy: Field-level errors
 *
 * ✅ Production Checklist:
 * - [ ] All form fields tested
 * - [ ] Validation rules tested
 * - [ ] Error messages accessible
 * - [ ] Loading states tested
 * - [ ] Success flow tested
 * - [ ] Async operations tested
 * - [ ] Edge cases covered
 *
 * 📝 Test Documentation:
 * - Write clear test descriptions
 * - Group related tests
 * - Document assumptions
 */

// RegistrationForm.jsx (Production component)
const RegistrationForm = ({ onSubmit }) => {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
  });

  const [errors, setErrors] = useState({});
  const [loading, setLoading] = useState(false);
  const [checkingEmail, setCheckingEmail] = useState(false);
  const [submitSuccess, setSubmitSuccess] = useState(false);

  const validateField = (name, value) => {
    switch (name) {
      case 'username':
        if (!value) return 'Username is required';
        if (value.length < 3) return 'Username must be at least 3 characters';
        return '';

      case 'email':
        if (!value) return 'Email is required';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
          return 'Invalid email format';
        }
        return '';

      case 'password':
        if (!value) return 'Password is required';
        if (value.length < 8) return 'Password must be at least 8 characters';
        if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          return 'Password must contain uppercase, lowercase, and number';
        }
        return '';

      case 'confirmPassword':
        if (value !== formData.password) return 'Passwords do not match';
        return '';

      default:
        return '';
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));

    // Real-time validation
    const error = validateField(name, value);
    setErrors((prev) => ({ ...prev, [name]: error }));

    // Check email availability (debounced in real app)
    if (name === 'email' && !error && value) {
      setCheckingEmail(true);
      setTimeout(() => {
        if (value === 'taken@example.com') {
          setErrors((prev) => ({ ...prev, email: 'Email already in use' }));
        }
        setCheckingEmail(false);
      }, 500);
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    // Validate all fields
    const newErrors = {};
    Object.keys(formData).forEach((key) => {
      const error = validateField(key, formData[key]);
      if (error) newErrors[key] = error;
    });

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    setLoading(true);

    try {
      await onSubmit(formData);
      setSubmitSuccess(true);
    } catch (error) {
      setErrors({ submit: 'Registration failed. Please try again.' });
    } finally {
      setLoading(false);
    }
  };

  if (submitSuccess) {
    return (
      <div role='alert'>
        <h2>Registration Successful!</h2>
        <p>Welcome, {formData.username}!</p>
      </div>
    );
  }

  return (
    <form
      onSubmit={handleSubmit}
      noValidate
    >
      <h2>Create Account</h2>

      <div>
        <label htmlFor='username'>Username *</label>
        <input
          id='username'
          name='username'
          type='text'
          value={formData.username}
          onChange={handleChange}
          aria-invalid={!!errors.username}
          aria-describedby={errors.username ? 'username-error' : undefined}
        />
        {errors.username && (
          <span
            id='username-error'
            role='alert'
          >
            {errors.username}
          </span>
        )}
      </div>

      <div>
        <label htmlFor='email'>Email *</label>
        <input
          id='email'
          name='email'
          type='email'
          value={formData.email}
          onChange={handleChange}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {checkingEmail && <span>Checking availability...</span>}
        {errors.email && (
          <span
            id='email-error'
            role='alert'
          >
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor='password'>Password *</label>
        <input
          id='password'
          name='password'
          type='password'
          value={formData.password}
          onChange={handleChange}
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <span
            id='password-error'
            role='alert'
          >
            {errors.password}
          </span>
        )}
      </div>

      <div>
        <label htmlFor='confirmPassword'>Confirm Password *</label>
        <input
          id='confirmPassword'
          name='confirmPassword'
          type='password'
          value={formData.confirmPassword}
          onChange={handleChange}
          aria-invalid={!!errors.confirmPassword}
          aria-describedby={
            errors.confirmPassword ? 'confirm-error' : undefined
          }
        />
        {errors.confirmPassword && (
          <span
            id='confirm-error'
            role='alert'
          >
            {errors.confirmPassword}
          </span>
        )}
      </div>

      {errors.submit && (
        <div
          role='alert'
          style={{ color: 'red' }}
        >
          {errors.submit}
        </div>
      )}

      <button
        type='submit'
        disabled={loading || checkingEmail}
      >
        {loading ? 'Creating Account...' : 'Register'}
      </button>
    </form>
  );
};

// TODO: Write comprehensive test suite covering:
// - Form rendering
// - Field validation (each field)
// - Real-time validation
// - Email availability check
// - Password matching
// - Submit validation
// - Success flow
// - Error handling
// - Accessibility
💡 Solution
jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import RegistrationForm from './RegistrationForm';

/**
 * Comprehensive test suite for production registration form
 *
 * Coverage areas:
 * 1. Initial render and form structure
 * 2. Individual field validation
 * 3. Real-time validation feedback
 * 4. Async email checking
 * 5. Form submission (success/failure)
 * 6. Accessibility compliance
 * 7. Edge cases and error recovery
 */

describe('RegistrationForm', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  describe('Initial Render', () => {
    test('renders all form fields', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      expect(
        screen.getByRole('heading', { name: /create account/i }),
      ).toBeInTheDocument();

      expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
      expect(screen.getByLabelText(/^email/i)).toBeInTheDocument();
      expect(screen.getByLabelText(/^password/i)).toBeInTheDocument();
      expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();

      expect(
        screen.getByRole('button', { name: /register/i }),
      ).toBeInTheDocument();
    });

    test('all fields are initially empty', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      expect(screen.getByLabelText(/username/i)).toHaveValue('');
      expect(screen.getByLabelText(/^email/i)).toHaveValue('');
      expect(screen.getByLabelText(/^password/i)).toHaveValue('');
      expect(screen.getByLabelText(/confirm password/i)).toHaveValue('');
    });
  });

  describe('Username Validation', () => {
    test('shows error for empty username on change', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const usernameInput = screen.getByLabelText(/username/i);

      fireEvent.change(usernameInput, { target: { value: '' } });
      fireEvent.blur(usernameInput);

      expect(screen.getByText(/username is required/i)).toBeInTheDocument();
    });

    test('shows error for username too short', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const usernameInput = screen.getByLabelText(/username/i);

      fireEvent.change(usernameInput, { target: { value: 'ab' } });

      expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
    });

    test('accepts valid username', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const usernameInput = screen.getByLabelText(/username/i);

      fireEvent.change(usernameInput, { target: { value: 'johndoe' } });

      expect(screen.queryByText(/username/i)).not.toBeInTheDocument();
    });
  });

  describe('Email Validation', () => {
    test('shows error for invalid email format', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const emailInput = screen.getByLabelText(/^email/i);

      fireEvent.change(emailInput, { target: { value: 'notanemail' } });

      expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
    });

    test('checks email availability asynchronously', async () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const emailInput = screen.getByLabelText(/^email/i);

      fireEvent.change(emailInput, { target: { value: 'test@example.com' } });

      // Should show checking state
      expect(screen.getByText(/checking availability/i)).toBeInTheDocument();

      // Wait for check to complete
      await waitFor(() => {
        expect(
          screen.queryByText(/checking availability/i),
        ).not.toBeInTheDocument();
      });
    });

    test('shows error if email is already taken', async () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const emailInput = screen.getByLabelText(/^email/i);

      fireEvent.change(emailInput, { target: { value: 'taken@example.com' } });

      // Wait for async check
      const errorMessage = await screen.findByText(/email already in use/i);
      expect(errorMessage).toBeInTheDocument();
    });
  });

  describe('Password Validation', () => {
    test('shows error for password too short', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const passwordInput = screen.getByLabelText(/^password/i);

      fireEvent.change(passwordInput, { target: { value: 'short' } });

      expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
    });

    test('shows error for weak password', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const passwordInput = screen.getByLabelText(/^password/i);

      fireEvent.change(passwordInput, { target: { value: 'alllowercase' } });

      expect(
        screen.getByText(/must contain uppercase, lowercase, and number/i),
      ).toBeInTheDocument();
    });

    test('accepts strong password', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const passwordInput = screen.getByLabelText(/^password/i);

      fireEvent.change(passwordInput, { target: { value: 'StrongPass123' } });

      expect(screen.queryByText(/password must/i)).not.toBeInTheDocument();
    });
  });

  describe('Password Confirmation', () => {
    test('shows error when passwords do not match', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fireEvent.change(screen.getByLabelText(/^password/i), {
        target: { value: 'Password123' },
      });

      fireEvent.change(screen.getByLabelText(/confirm password/i), {
        target: { value: 'Different123' },
      });

      expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
    });

    test('no error when passwords match', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fireEvent.change(screen.getByLabelText(/^password/i), {
        target: { value: 'Password123' },
      });

      fireEvent.change(screen.getByLabelText(/confirm password/i), {
        target: { value: 'Password123' },
      });

      expect(
        screen.queryByText(/passwords do not match/i),
      ).not.toBeInTheDocument();
    });
  });

  describe('Form Submission', () => {
    const fillValidForm = () => {
      fireEvent.change(screen.getByLabelText(/username/i), {
        target: { value: 'johndoe' },
      });

      fireEvent.change(screen.getByLabelText(/^email/i), {
        target: { value: 'john@example.com' },
      });

      fireEvent.change(screen.getByLabelText(/^password/i), {
        target: { value: 'SecurePass123' },
      });

      fireEvent.change(screen.getByLabelText(/confirm password/i), {
        target: { value: 'SecurePass123' },
      });
    };

    test('prevents submission with invalid data', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const submitButton = screen.getByRole('button', { name: /register/i });
      fireEvent.click(submitButton);

      // Should show validation errors
      expect(screen.getByText(/username is required/i)).toBeInTheDocument();
      expect(mockOnSubmit).not.toHaveBeenCalled();
    });

    test('submits form with valid data', async () => {
      mockOnSubmit.mockResolvedValue({});

      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fillValidForm();

      // Wait for email check
      await waitFor(() => {
        expect(screen.queryByText(/checking/i)).not.toBeInTheDocument();
      });

      const submitButton = screen.getByRole('button', { name: /register/i });
      fireEvent.click(submitButton);

      await waitFor(() => {
        expect(mockOnSubmit).toHaveBeenCalledWith({
          username: 'johndoe',
          email: 'john@example.com',
          password: 'SecurePass123',
          confirmPassword: 'SecurePass123',
        });
      });
    });

    test('shows loading state during submission', async () => {
      mockOnSubmit.mockImplementation(
        () =>
          new Promise((resolve) => {
            setTimeout(resolve, 1000);
          }),
      );

      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fillValidForm();

      await waitFor(() => {
        expect(screen.queryByText(/checking/i)).not.toBeInTheDocument();
      });

      fireEvent.click(screen.getByRole('button', { name: /register/i }));

      expect(
        screen.getByRole('button', { name: /creating account/i }),
      ).toBeDisabled();
    });

    test('shows success message after successful submission', async () => {
      mockOnSubmit.mockResolvedValue({});

      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fillValidForm();

      await waitFor(() => {
        expect(screen.queryByText(/checking/i)).not.toBeInTheDocument();
      });

      fireEvent.click(screen.getByRole('button', { name: /register/i }));

      const successMessage = await screen.findByRole('alert');
      expect(successMessage).toHaveTextContent(/registration successful/i);
      expect(successMessage).toHaveTextContent(/welcome, johndoe/i);
    });

    test('shows error message on submission failure', async () => {
      mockOnSubmit.mockRejectedValue(new Error('Server error'));

      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fillValidForm();

      await waitFor(() => {
        expect(screen.queryByText(/checking/i)).not.toBeInTheDocument();
      });

      fireEvent.click(screen.getByRole('button', { name: /register/i }));

      const errorMessage = await screen.findByText(/registration failed/i);
      expect(errorMessage).toBeInTheDocument();
    });
  });

  describe('Accessibility', () => {
    test('error messages are announced to screen readers', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fireEvent.change(screen.getByLabelText(/username/i), {
        target: { value: 'ab' },
      });

      const errorMessage = screen.getByText(/at least 3 characters/i);
      expect(errorMessage).toHaveAttribute('role', 'alert');
    });

    test('invalid fields have aria-invalid attribute', () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      const usernameInput = screen.getByLabelText(/username/i);
      fireEvent.change(usernameInput, { target: { value: 'ab' } });

      expect(usernameInput).toHaveAttribute('aria-invalid', 'true');
      expect(usernameInput).toHaveAttribute(
        'aria-describedby',
        'username-error',
      );
    });

    test('submit button disabled during async operations', async () => {
      render(<RegistrationForm onSubmit={mockOnSubmit} />);

      fireEvent.change(screen.getByLabelText(/^email/i), {
        target: { value: 'test@example.com' },
      });

      const submitButton = screen.getByRole('button', { name: /register/i });
      expect(submitButton).toBeDisabled();

      await waitFor(() => {
        expect(submitButton).not.toBeDisabled();
      });
    });
  });
});

// Test Summary:
// ✓ Initial Render (2 tests)
// ✓ Username Validation (3 tests)
// ✓ Email Validation (3 tests)
// ✓ Password Validation (3 tests)
// ✓ Password Confirmation (2 tests)
// ✓ Form Submission (6 tests)
// ✓ Accessibility (3 tests)
//
// Total: 22 tests
// All scenarios covered: validation, async operations, success/error flows, accessibility

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

Bảng So Sánh Query Methods

Query TypeKhi Nào DùngThrow Error?ReturnsAsync?Use Case
getByElement PHẢI tồn tại✅ YesElement❌ NoHeading, buttons, labels
queryByElement có thể không có❌ NoElement | null❌ NoAsserting absence
findByElement xuất hiện sau✅ YesPromise<Element>✅ YesAsync rendering
getAllByMultiple elements✅ YesElement[]❌ NoLists, options
queryAllByMultiple (optional)❌ NoElement[]❌ NoConditional lists
findAllByMultiple async✅ YesPromise<Element[]>✅ YesAsync lists

Bảng So Sánh Query Priority

PriorityMethodAccessible?User-Like?When to UseExample
🥇 1getByRole✅ Best✅ BestAlways preferredgetByRole('button', { name: /submit/i })
🥈 2getByLabelText✅ Great✅ GreatForm inputsgetByLabelText('Email')
🥉 3getByPlaceholderText⚠️ OK⚠️ OKNo label availablegetByPlaceholderText('Enter email')
4getByText✅ Good✅ GoodContent verificationgetByText('Welcome')
5getByDisplayValue⚠️ OK⚠️ OKForm current valuegetByDisplayValue('John')
6getByAltText✅ Good✅ GoodImagesgetByAltText('Profile photo')
7getByTitle⚠️ Rare⚠️ RareTitle attributegetByTitle('Close')
🚫 LastgetByTestId❌ No❌ NoLast resort onlygetByTestId('custom-element')

Decision Tree: Chọn Query Method

START: Cần query element

├─ Element tồn tại NGAY?
│  ├─ YES → Dùng getBy*
│  │    └─ Có nhiều elements?
│  │       ├─ YES → getAllBy*
│  │       └─ NO → getBy*
│  │
│  └─ NO → Element xuất hiện SAU?
│     ├─ Async operation → findBy* / findAllBy*
│     └─ Conditional render → queryBy* / queryAllBy*

└─ Chọn query type CỤ THỂ:
   1. Element có role? → getByRole (BEST)
   2. Form input? → getByLabelText
   3. Text content? → getByText
   4. Image? → getByAltText
   5. Last resort? → getByTestId (AVOID)

Trade-offs Matrix

ApproachProsConsWhen to Use
Test ImplementationFast to write, specificBreaks on refactor, not user-centric❌ Never (anti-pattern)
Snapshot TestingComprehensive, auto-generatedBrittle, hard to review⚠️ Sparingly for stable UI
RTL Behavior TestingUser-centric, refactor-safe, accessibleSlightly verbose, need query knowledge✅ Always (best practice)
E2E TestingFull system, realisticSlow, flaky, expensive⚠️ Critical user flows only

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

Bug 1: Query Method Sai

jsx
// Component
const AlertMessage = ({ show, message }) => {
  return show ? <div role='alert'>{message}</div> : null;
};

// ❌ Test bị lỗi
test('does not show alert initially', () => {
  render(
    <AlertMessage
      show={false}
      message='Error'
    />,
  );

  // 💥 ERROR: Unable to find an element with role="alert"
  expect(screen.getByRole('alert')).not.toBeInTheDocument();
});

// 🤔 DEBUG QUESTIONS:
// 1. Tại sao test fail?
// 2. getBy vs queryBy khác nhau như thế nào?
// 3. Khi nào dùng getBy, khi nào dùng queryBy?

// ✅ FIX:
test('does not show alert initially', () => {
  render(
    <AlertMessage
      show={false}
      message='Error'
    />,
  );

  // Use queryBy for elements that might not exist
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  // Or: expect(screen.queryByRole('alert')).toBeNull();
});

// 💡 LESSON:
// - getBy throws if element not found
// - queryBy returns null if element not found
// - Dùng queryBy để assert ABSENCE (not.toBeInTheDocument)
// - Dùng getBy để assert PRESENCE (toBeInTheDocument)

Bug 2: Async Test Không Đợi

jsx
// Component
const DataLoader = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    setTimeout(() => {
      setData('Loaded data');
    }, 1000);
  }, []);

  return data ? <p>{data}</p> : <p>Loading...</p>;
};

// ❌ Test fail
test('loads data', () => {
  render(<DataLoader />);

  // 💥 ERROR: Unable to find element with text "Loaded data"
  expect(screen.getByText('Loaded data')).toBeInTheDocument();
});

// 🤔 DEBUG QUESTIONS:
// 1. Tại sao không tìm thấy "Loaded data"?
// 2. Data xuất hiện khi nào?
// 3. Cần dùng query method nào?

// ✅ FIX:
test('loads data', async () => {
  render(<DataLoader />);

  // Initially shows loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for data to appear
  const dataElement = await screen.findByText('Loaded data');
  expect(dataElement).toBeInTheDocument();

  // Loading should be gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

// 💡 LESSON:
// - findBy returns Promise → wait for async operations
// - Always await findBy queries
// - Test must be async function
// - Verify both loading and loaded states

Bug 3: Query Priority Sai

jsx
// Component
const SubmitButton = ({ onClick }) => {
  return (
    <button
      onClick={onClick}
      className='btn-primary'
      data-testid='submit-btn'
    >
      Submit Form
    </button>
  );
};

// ❌ Anti-pattern tests
test('❌ BAD: using testid', () => {
  render(<SubmitButton onClick={() => {}} />);

  const button = screen.getByTestId('submit-btn');
  expect(button).toBeInTheDocument();
});

test('❌ BAD: using className', () => {
  render(<SubmitButton onClick={() => {}} />);

  // This doesn't even work in RTL!
  const button = screen.getByClassName('btn-primary'); // ❌ Not a valid query
});

// ✅ CORRECT: Use semantic queries
test('✅ GOOD: using role', () => {
  const mockClick = jest.fn();
  render(<SubmitButton onClick={mockClick} />);

  // Best practice: accessible query
  const button = screen.getByRole('button', { name: /submit form/i });
  expect(button).toBeInTheDocument();

  fireEvent.click(button);
  expect(mockClick).toHaveBeenCalled();
});

// 🤔 DEBUG QUESTIONS:
// 1. Tại sao không nên dùng testid?
// 2. User tìm button như thế nào?
// 3. Query nào accessible nhất?

// 💡 LESSON:
// Query Priority:
// 1. getByRole (BEST - accessible)
// 2. getByLabelText (forms)
// 3. getByText (content)
// 4. getByTestId (LAST RESORT)
//
// Avoid:
// - className (not user-visible)
// - wrapper.find() (implementation detail)
// - enzyme-style queries

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

Knowledge Check

jsx
// Tự đánh giá kiến thức của bạn:

// 1. RTL Philosophy
[ ] Tôi hiểu "test như user" nghĩa là gì
[ ] Tôi biết tại sao không nên test implementation
[ ] Tôi có thể giải thích behavior vs implementation testing

// 2. Render & Screen
[ ] Tôi biết cách dùng render()
[ ] Tôi hiểu screen là global object
[ ] Tôi biết khi nào dùng screen.debug()

// 3. Query Methods
[ ] Tôi phân biệt được getBy/queryBy/findBy
[ ] Tôi biết khi nào dùng method nào
[ ] Tôi hiểu getAllBy/queryAllBy/findAllBy

// 4. Query Priority
[ ] Tôi ưu tiên getByRole trước
[ ] Tôi tránh dùng getByTestId
[ ] Tôi biết query methods theo accessibility

// 5. User Events
[ ] Tôi biết dùng fireEvent cho interactions
[ ] Tôi test được form submissions
[ ] Tôi handle được async events

// 6. Assertions
[ ] Tôi dùng đúng matchers (toBeInTheDocument, etc.)
[ ] Tôi test cả positive và negative cases
[ ] Tôi verify callbacks được gọi

// 7. Async Testing
[ ] Tôi dùng await với findBy
[ ] Tôi dùng waitFor khi cần
[ ] Tôi handle được loading states

// 8. Best Practices
[ ] Tests của tôi readable và maintainable
[ ] Tôi group related tests trong describe
[ ] Tôi viết clear test descriptions

Code Review Checklist

jsx
// Checklist review code tests của bạn:

QUERY SELECTION
[ ] Dùng getByRole khi có thể
[ ] Tránh getByTestId (chỉ last resort)
[ ] Queries phản ánh user behavior

ASYNC HANDLING
[ ] Dùng findBy cho async elements
[ ] Await tất cả findBy calls
[ ] Test function async khi cần

ASSERTIONS
[ ] Assert cả presence absence
[ ] Dùng queryBy cho not.toBeInTheDocument
[ ] Verify callbacks với jest.fn()

TEST STRUCTURE
[ ] Mỗi test focused isolated
[ ] Clear test descriptions
[ ] Arrange-Act-Assert pattern

ACCESSIBILITY
[ ] Queries accessible (role, label)
[ ] Test aria attributes khi
[ ] Verify screen reader experience

COVERAGE
[ ] Happy path tested
[ ] Error cases tested
[ ] Edge cases tested
[ ] Loading states tested

MAINTAINABILITY
[ ] Tests dễ đọc
[ ] Không hard-code values
[ ] Setup helpers cho repeated logic
[ ] Mock external dependencies

🏠 BÀI TẬP VỀ NHÀ

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

Bài 1: Test Component Tree

jsx
/**
 * Test component có parent-child relationship
 */

// ParentChild.jsx
const Parent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Parent Component</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Child count={count} />
    </div>
  );
};

const Child = ({ count }) => {
  return (
    <div>
      <h2>Child Component</h2>
      <p>Count from parent: {count}</p>
    </div>
  );
};

// TODO: Write tests for:
// 1. Parent renders với child
// 2. Child nhận props từ parent
// 3. Parent state update → Child re-renders
// 4. Verify text trong child thay đổi

Nâng cao (60 phút)

Bài 2: Multi-Step Form Testing

jsx
/**
 * Test wizard form với multiple steps
 */

// Wizard.jsx
const Wizard = () => {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    phone: '',
  });

  const nextStep = () => setStep(step + 1);
  const prevStep = () => setStep(step - 1);

  const updateField = (field, value) => {
    setFormData({ ...formData, [field]: value });
  };

  return (
    <div>
      <h2>Step {step} of 3</h2>

      {step === 1 && (
        <div>
          <label>Name</label>
          <input
            value={formData.name}
            onChange={(e) => updateField('name', e.target.value)}
          />
          <button
            onClick={nextStep}
            disabled={!formData.name}
          >
            Next
          </button>
        </div>
      )}

      {step === 2 && (
        <div>
          <label>Email</label>
          <input
            type='email'
            value={formData.email}
            onChange={(e) => updateField('email', e.target.value)}
          />
          <button onClick={prevStep}>Back</button>
          <button
            onClick={nextStep}
            disabled={!formData.email}
          >
            Next
          </button>
        </div>
      )}

      {step === 3 && (
        <div>
          <label>Phone</label>
          <input
            value={formData.phone}
            onChange={(e) => updateField('phone', e.target.value)}
          />
          <button onClick={prevStep}>Back</button>
          <button disabled={!formData.phone}>Submit</button>

          <div>
            <h3>Summary</h3>
            <p>Name: {formData.name}</p>
            <p>Email: {formData.email}</p>
            <p>Phone: {formData.phone}</p>
          </div>
        </div>
      )}
    </div>
  );
};

// TODO: Write comprehensive tests for:
// 1. Navigation giữa các steps
// 2. Form data persistence across steps
// 3. Validation (Next button disabled)
// 4. Back button functionality
// 5. Summary display
// 6. Complete user flow từ step 1 → 3

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. React Testing Library Docs

  2. Common Mistakes with RTL

Đọc thêm

  1. Testing Playground

  2. Query Priority Guide

  3. Async Testing


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

Kiến thức nền

  • Ngày 53: Testing Philosophy

    • AAA pattern
    • Test behavior not implementation
    • User-centric testing
  • React Fundamentals (Ngày 1-52):

    • Components, props, state
    • Events và forms
    • Conditional rendering
    • Async operations

Hướng tới

  • Ngày 55: Testing Hooks & Context

    • renderHook()
    • Testing custom hooks
    • Context providers trong tests
  • Ngày 56: Mocking API Calls

    • Mock Service Worker (MSW)
    • Testing async data fetching
    • Loading/error states

💡 SENIOR INSIGHTS

Cân Nhắc Production

1. Test Coverage Goals

Không phải 100% coverage là tốt nhất:
- Critical paths: 100% coverage
- UI variations: 80% coverage
- Edge cases: Đủ để confident
- Implementation details: 0% (don't test)

Metric quan trọng:
- Confidence level (có tự tin ship không?)
- Test maintenance cost
- Bug detection rate

2. Testing Strategy

// ❌ BAD: Test mọi thứ
test('button has correct class', () => {
  render(<Button />);
  expect(screen.getByRole('button')).toHaveClass('btn-primary');
});

// ✅ GOOD: Test behavior
test('submits form when clicked', () => {
  render(<Form />);
  fireEvent.click(screen.getByRole('button', { name: /submit/i }));
  expect(mockSubmit).toHaveBeenCalled();
});

Priority:
1. User flows (can they complete tasks?)
2. Error handling (graceful failures?)
3. Accessibility (everyone can use it?)
4. Edge cases (what can break?)

3. Performance Considerations

jsx
// Tests chạy chậm?

// ❌ Render toàn bộ app tree
test('header shows user name', () => {
  render(<App />); // Renders everything!
  // ...
});

// ✅ Render chỉ component cần test
test('header shows user name', () => {
  render(<Header user={{ name: 'John' }} />); // Isolated!
  // ...
});

Tips:
- Mock heavy dependencies
- Use beforeEach wisely
- Parallel test execution (jest --maxWorkers)
- Skip expensive setup cho simple tests

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

Junior Level:

Q1: "getBy và queryBy khác nhau như thế nào?"

Expected Answer:
- getBy throws error nếu không tìm thấy element
- queryBy returns null nếu không tìm thấy
- Dùng getBy để assert element TỒN TẠI
- Dùng queryBy để assert element KHÔNG TỒN TẠI
- Example code minh họa

Q2: "Tại sao RTL recommend dùng getByRole?"

Expected Answer:
- Reflects user perspective (screen reader)
- Enforces accessibility
- Semantic và meaningful
- Less likely to break on refactor
- Example: getByRole('button', { name: /submit/i })

Mid Level:

Q3: "Làm sao test async component trong RTL?"

Expected Answer:
- Use findBy queries (returns Promise)
- Use waitFor for complex scenarios
- Test loading states
- Test error states
- Clean up async operations
- Example:
  const data = await screen.findByText('Loaded');
  expect(data).toBeInTheDocument();

Q4: "Query priority trong RTL là gì? Tại sao?"

Expected Answer:
Priority order:
1. getByRole - Most accessible
2. getByLabelText - Forms
3. getByText - Content
4. getByTestId - Last resort

Rationale:
- Reflects user behavior
- Accessibility compliance
- Maintainable tests
- Discourage implementation testing

Senior Level:

Q5: "Design testing strategy cho large application"

Expected Answer:
1. Test Pyramid:
   - 70% Unit (components, utilities)
   - 20% Integration (features)
   - 10% E2E (critical flows)

2. What to Test:
   - User workflows
   - Error boundaries
   - Accessibility
   - Performance (loading states)

3. What NOT to Test:
   - Implementation details
   - Third-party library internals
   - CSS styling (use visual regression)

4. Infrastructure:
   - Shared test utilities
   - Mock providers
   - Test data factories
   - CI/CD integration

5. Maintenance:
   - Refactor-safe tests
   - Clear test descriptions
   - Avoid brittle selectors

War Stories

Story 1: The Snapshot Disaster

Situation:
Team có 500+ snapshot tests. Mỗi lần update UI,
phải review hàng trăm snapshot changes.

Problem:
- Snapshots quá brittle
- Hard to review (auto-approve common)
- Bugs slipped through
- Tests không document behavior

Solution:
- Migrate sang RTL behavior tests
- Keep snapshots chỉ cho static components
- Focus on user interactions
- Test coverage tăng 30%

Lesson:
"Snapshots are useful, but don't replace real tests.
Test behavior, not markup."

Story 2: The TestID Trap

Situation:
Toàn bộ tests dùng data-testid.
Product team muốn refactor HTML structure.

Problem:
- data-testid everywhere trong JSX (ugly)
- Tests không enforce accessibility
- Refactor broke tests unnecessarily
- No screen reader testing

Solution:
- Gradual migration to getByRole
- Add ARIA labels where missing
- Improved accessibility score
- Tests more maintainable

Lesson:
"If your test would fail when you improve accessibility,
your test is testing the wrong thing."

Story 3: The Async Race Condition

Situation:
Tests pass locally, fail trong CI randomly.

Problem:
test('loads data', () => {
  render(<DataLoader />);
  // ❌ No waiting!
  expect(screen.getByText('Data')).toBeInTheDocument();
});

Solution:
test('loads data', async () => {
  render(<DataLoader />);
  // ✅ Wait for async
  const data = await screen.findByText('Data');
  expect(data).toBeInTheDocument();
});

Added:
- Timeout configuration
- Better error messages
- Loading state tests

Lesson:
"Always await async operations. If test is flaky,
it's probably a timing issue."

🎯 Preview Ngày Mai

Ngày 55: Testing Hooks & Context

Ngày mai chúng ta sẽ học:

  • Testing custom hooks với renderHook()
  • Testing components sử dụng Context
  • Wrapper pattern cho providers
  • Testing hook dependencies
  • Testing hook cleanup

Concepts mới:

  • renderHook() từ @testing-library/react-hooks
  • Context providers trong test environment
  • Testing hook return values
  • Testing hook re-renders

Chuẩn bị:

  • Review custom hooks đã viết (Ngày 24, 29)
  • Review Context API (Ngày 36-38)
  • Suy nghĩ: Làm sao test logic không có UI?

Hẹn gặp lại! 🚀

Personal tech knowledge base