| name | react-testing-library |
| description | Write production-grade React tests with Testing Library, MSW, and comprehensive coverage patterns |
| sasmp_version | 2.0.0 |
| bonded_agent | 07-testing-deployment |
| bond_type | PRIMARY_BOND |
| input_validation | [object Object] |
| output_format | [object Object] |
| error_handling | [object Object] |
| observability | [object Object] |
React Testing Library Skill
Overview
Master React Testing Library for writing maintainable tests that focus on user behavior rather than implementation details.
Learning Objectives
- Write component tests with RTL
- Test user interactions
- Handle async operations
- Test hooks and context
- Follow testing best practices
Quick Start
Installation
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
Setup
// setupTests.js
import '@testing-library/jest-dom';
Basic Component Testing
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
it('renders button text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Queries
Priority Order
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');
// 2. Semantic queries
screen.getByAltText('Profile picture');
screen.getByTitle('Close');
// 3. Test IDs (last resort)
screen.getByTestId('custom-element');
Query Variants
// getBy - throws error if not found
screen.getByText('Hello');
// queryBy - returns null if not found
screen.queryByText('Hello'); // Use for asserting non-existence
// findBy - async, waits for element
await screen.findByText('Hello'); // Use for async content
Testing Forms
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('submits form with correct data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
it('shows validation errors', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
});
Testing Async Operations
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';
const server = setupServer(
rest.get('/api/users/:userId', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'John Doe' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserProfile', () => {
it('loads and displays user data', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it('handles API errors', async () => {
server.use(
rest.get('/api/users/:userId', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile userId={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
Testing Hooks
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Testing Context
// ThemeToggle.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import ThemeToggle from './ThemeToggle';
describe('ThemeToggle', () => {
it('toggles theme', async () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button'));
expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument();
});
});
Custom Render Utility
// test-utils.jsx
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';
function AllProviders({ children }) {
return (
<BrowserRouter>
<ThemeProvider>
{children}
</ThemeProvider>
</BrowserRouter>
);
}
function customRender(ui, options) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
Best Practices
- Test user behavior, not implementation
- Use accessible queries (role, label)
- Avoid testing internal state
- Use userEvent for realistic interactions
- Mock external dependencies (API calls)
- Test error states and edge cases
- Keep tests simple and focused
Common Patterns
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Wait for multiple elements
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
// Debugging
screen.debug(); // Print DOM
screen.logTestingPlaygroundURL(); // Get Testing Playground URL
Practice Exercises
- Test form validation
- Test async data fetching
- Test user authentication flow
- Test routing navigation
- Test modal interactions
- Test list filtering
- Test error boundaries
Resources
Flaky Test Prevention
// Configure Jest retry for CI
// jest.config.js
module.exports = {
testRetry: process.env.CI ? 2 : 0,
testTimeout: 10000,
};
// Robust async pattern
it('handles async operations reliably', async () => {
render(<AsyncComponent />);
// Use findBy for async content
expect(await screen.findByText(/loaded/i, {}, { timeout: 5000 }))
.toBeInTheDocument();
// Ensure loading is gone
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
MSW 2.0 Setup
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'John' }]);
}),
http.post('/api/users', async ({ request }) => {
const user = await request.json();
return HttpResponse.json({ ...user, id: Date.now() }, { status: 201 });
}),
// Error simulation
http.get('/api/error', () => {
return HttpResponse.json({ message: 'Server Error' }, { status: 500 });
}),
];
Coverage Configuration
// jest.config.js
module.exports = {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Version: 2.0.0 Last Updated: 2025-12-30 SASMP Version: 2.0.0 Difficulty: Intermediate Estimated Time: 2-3 weeks Prerequisites: React Fundamentals, Jest Basics Changelog: Added MSW 2.0, flaky test prevention, and coverage config