Testing React Apps
Cài đặt
# Với Vite
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
# Hoặc với Create React App (Jest có sẵn)
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
React Testing Library - Nguyên tắc
“Test your components the way users use them.”
// ✅ Query theo những gì user thấy
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByText('Welcome, Alice!');
screen.getByPlaceholderText('Search...');
// ✅ Khi không có accessible text
screen.getByTestId('loading-spinner');
// ❌ Tránh query theo implementation details
screen.getByClassName('btn-primary'); // Fragile
screen.getByAttribute('data-id', '42'); // Fragile
Unit Testing Components
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Submit</Button>);
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not call onClick when disabled', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Submit</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
Testing Forms
// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const user = userEvent.setup();
it('shows validation errors for empty submit', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
it('shows error for invalid email', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText('Invalid email format')).toBeInTheDocument();
});
it('calls onSubmit with correct data when valid', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'securepass123');
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'securepass123',
});
});
});
});
Testing Async Components
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { UserProfile } from './UserProfile';
// Mock API với MSW (Mock Service Worker)
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
if (params.id === '1') {
return HttpResponse.json({
id: 1,
name: 'Alice Johnson',
email: 'alice@example.com',
});
}
return new HttpResponse(null, { status: 404 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserProfile', () => {
it('shows loading state initially', () => {
render(<UserProfile userId={1} />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// hoặc
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data after loading', async () => {
render(<UserProfile userId={1} />);
expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('shows error message when user not found', async () => {
render(<UserProfile userId={999} />);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/not found|error/i)).toBeInTheDocument();
});
it('retries on server error', async () => {
server.use(
http.get('/api/users/1', () =>
new HttpResponse(null, { status: 500 })
)
);
render(<UserProfile userId={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
Testing Custom Hooks
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets count', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
// useLocalStorage.test.ts
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns initial value when no stored value', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
it('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('key', ''));
act(() => {
result.current[1]('new value');
});
expect(localStorage.getItem('key')).toBe('"new value"');
expect(result.current[0]).toBe('new value');
});
});
Testing với Context
// Tạo custom render wrapper
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Không retry trong test
gcTime: 0,
},
},
});
}
interface RenderOptions {
initialRoute?: string;
initialUser?: User;
}
function renderWithProviders(
ui: React.ReactElement,
{ initialRoute = '/', initialUser }: RenderOptions = {}
) {
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider initialUser={initialUser}>
<MemoryRouter initialEntries={[initialRoute]}>
{children}
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper });
}
// Test với context
describe('ProtectedRoute', () => {
it('redirects to login when not authenticated', () => {
renderWithProviders(<ProtectedPage />, { initialRoute: '/dashboard' });
expect(screen.getByText(/login/i)).toBeInTheDocument();
});
it('renders content when authenticated', () => {
renderWithProviders(<ProtectedPage />, {
initialUser: { id: 1, name: 'Alice', roles: ['user'] },
});
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
});
});