📅 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)
- Testing Philosophy (Ngày 53): Tại sao nên "test behavior, not implementation"?
- React Fundamentals: Component với props + state + events hoạt động như thế nào?
- DOM APIs:
querySelector,textContent,getAttributedù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
// ❌ 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."
// ✅ 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ếp | Test output (DOM) | User không thấy state |
| Dùng className để query | Dùng role/label/text | User không thấy class |
| Test implementation details | Test behavior | Refactor-safe tests |
| Wrapper.find('button') | screen.getByRole('button') | User perspective |
| Snapshot mọi thứ | Targeted assertions | Snapshots dễ outdated |
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Pattern Cơ Bản ⭐
Setup Test Environment
// 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 componentscreenlà global object để query DOMfireEventtrigger events- Assertions kiểm tra DOM, không phải internal state
Demo 2: Query Methods Deep Dive ⭐⭐
Understanding getBy vs queryBy vs findBy
// 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 operationsDemo 3: Query Priority & Best Practices ⭐⭐⭐
// 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):
- getByRole - Accessible và semantic
- getByLabelText - Forms
- getByPlaceholderText - Forms (fallback)
- getByText - Content
- getByDisplayValue - Form current value
- getByAltText - Images
- getByTitle - Title attribute
- 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)
/**
* 🎯 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
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)
/**
* 🎯 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
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)
/**
* 🎯 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
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)
/**
* 🎯 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
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)
/**
* 🎯 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
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 Type | Khi Nào Dùng | Throw Error? | Returns | Async? | Use Case |
|---|---|---|---|---|---|
| getBy | Element PHẢI tồn tại | ✅ Yes | Element | ❌ No | Heading, buttons, labels |
| queryBy | Element có thể không có | ❌ No | Element | null | ❌ No | Asserting absence |
| findBy | Element xuất hiện sau | ✅ Yes | Promise<Element> | ✅ Yes | Async rendering |
| getAllBy | Multiple elements | ✅ Yes | Element[] | ❌ No | Lists, options |
| queryAllBy | Multiple (optional) | ❌ No | Element[] | ❌ No | Conditional lists |
| findAllBy | Multiple async | ✅ Yes | Promise<Element[]> | ✅ Yes | Async lists |
Bảng So Sánh Query Priority
| Priority | Method | Accessible? | User-Like? | When to Use | Example |
|---|---|---|---|---|---|
| 🥇 1 | getByRole | ✅ Best | ✅ Best | Always preferred | getByRole('button', { name: /submit/i }) |
| 🥈 2 | getByLabelText | ✅ Great | ✅ Great | Form inputs | getByLabelText('Email') |
| 🥉 3 | getByPlaceholderText | ⚠️ OK | ⚠️ OK | No label available | getByPlaceholderText('Enter email') |
| 4 | getByText | ✅ Good | ✅ Good | Content verification | getByText('Welcome') |
| 5 | getByDisplayValue | ⚠️ OK | ⚠️ OK | Form current value | getByDisplayValue('John') |
| 6 | getByAltText | ✅ Good | ✅ Good | Images | getByAltText('Profile photo') |
| 7 | getByTitle | ⚠️ Rare | ⚠️ Rare | Title attribute | getByTitle('Close') |
| 🚫 Last | getByTestId | ❌ No | ❌ No | Last resort only | getByTestId('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
| Approach | Pros | Cons | When to Use |
|---|---|---|---|
| Test Implementation | Fast to write, specific | Breaks on refactor, not user-centric | ❌ Never (anti-pattern) |
| Snapshot Testing | Comprehensive, auto-generated | Brittle, hard to review | ⚠️ Sparingly for stable UI |
| RTL Behavior Testing | User-centric, refactor-safe, accessible | Slightly verbose, need query knowledge | ✅ Always (best practice) |
| E2E Testing | Full system, realistic | Slow, flaky, expensive | ⚠️ Critical user flows only |
🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Query Method Sai
// 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
// 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 statesBug 3: Query Priority Sai
// 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
// 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 descriptionsCode Review Checklist
// 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 là async khi cần
✅ ASSERTIONS
[ ] Assert cả presence và absence
[ ] Dùng queryBy cho not.toBeInTheDocument
[ ] Verify callbacks với jest.fn()
✅ TEST STRUCTURE
[ ] Mỗi test focused và isolated
[ ] Clear test descriptions
[ ] Arrange-Act-Assert pattern
✅ ACCESSIBILITY
[ ] Queries accessible (role, label)
[ ] Test aria attributes khi có
[ ] 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
/**
* 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 đổiNâng cao (60 phút)
Bài 2: Multi-Step Form Testing
/**
* 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
React Testing Library Docs
- https://testing-library.com/docs/react-testing-library/intro/
- Focus: API Reference, Queries, Example
Common Mistakes with RTL
- https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
- Học anti-patterns để tránh
Đọc thêm
Testing Playground
- https://testing-playground.com/
- Tool để generate queries tốt nhất
Query Priority Guide
- https://testing-library.com/docs/queries/about/#priority
- Chi tiết về query selection
Async Testing
- https://testing-library.com/docs/dom-testing-library/api-async/
- findBy, waitFor patterns
🔗 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 rate2. 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
// 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 testsCâ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ọaQ2: "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 testingSenior 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 selectorsWar 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! 🚀