Testing ensures your React components work correctly and continue working as your app evolves. Without tests, every change risks breaking something. With tests, you can refactor confidently, catch bugs early, and ship reliable code. In this lesson, you'll learn how to test React components using Jest (test runner) and React Testing Library (testing utilities). Let's build confidence in your code! ๐งช
Why Test React Components?
- Catch bugs early - Before users find them
- Refactor confidently - Tests catch breaking changes
- Document behavior - Tests show how components should work
- Prevent regressions - Old bugs stay fixed
- Faster development - Less manual testing
- Better design - Testable code is better code
Testing Pyramid:
Unit Tests (70%): Test individual components in isolation
Integration Tests (20%): Test components working together
End-to-End Tests (10%): Test complete user flows
Focus on unit and integration tests for React components!
Setup: Jest and React Testing Library
Create React App (Built-in)
# Create React App includes testing setup automatically!
npx create-react-app my-app
# Already includes:
# - Jest (test runner)
# - React Testing Library
# - @testing-library/jest-dom (matchers)
# Run tests
npm test
# Watch mode starts automatically
# Press 'a' to run all tests
# Press 'q' to quitManual Setup (Vite, Next.js, etc.)
# Install dependencies
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
# For Vite/ES modules
npm install --save-dev @vitejs/plugin-react jsdom
# Create jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
}
# Create jest.setup.js
import '@testing-library/jest-dom'
# Add to package.json
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
}Your First Test
Simple Component
// Button.jsx
function Button({ children, onClick, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
)
}
export default ButtonSimple Test
// Button.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Button from './Button'
// Test suite
describe('Button', () => {
// Individual test
test('renders button with text', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
})
test('calls onClick when clicked', async () => {
const handleClick = jest.fn() // Mock function
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button')
await userEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
test('button is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
})
// Run: npm test
// Result: โ 3 tests passedTest Anatomy
import { render, screen } from '@testing-library/react'
test('description of what you are testing', () => {
// 1. ARRANGE - Set up the test
render(<Component prop="value" />)
// 2. ACT - Perform actions
const button = screen.getByRole('button')
await userEvent.click(button)
// 3. ASSERT - Check results
expect(button).toBeInTheDocument()
})
// Pattern: Arrange โ Act โ Assert (AAA)Querying Elements
Query Types
import { render, screen } from '@testing-library/react'
render(<MyComponent />)
// getBy* - Returns element or throws error (element must exist)
const button = screen.getByRole('button')
const heading = screen.getByText('Hello')
const input = screen.getByLabelText('Email')
// queryBy* - Returns element or null (element may not exist)
const optional = screen.queryByText('Optional')
expect(optional).toBeNull() // Use for asserting non-existence
// findBy* - Returns promise (for async elements)
const async = await screen.findByText('Loaded')
// *AllBy* - Returns array of elements
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
// Priority order (best to worst):
// 1. getByRole - Most accessible
// 2. getByLabelText - For forms
// 3. getByPlaceholderText
// 4. getByText
// 5. getByTestId - Last resortCommon Queries
// By Role (BEST - matches accessibility)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('heading', { level: 1 })
screen.getByRole('textbox', { name: /email/i })
// By Label (Good for forms)
screen.getByLabelText('Email')
screen.getByLabelText(/password/i)
// By Placeholder
screen.getByPlaceholderText('Enter email')
// By Text
screen.getByText('Welcome')
screen.getByText(/welcome/i) // Case insensitive regex
// By Test ID (Last resort)
screen.getByTestId('custom-element')
// <div data-testid="custom-element">
// Multiple elements
screen.getAllByRole('listitem')
screen.getAllByText(/item/i)
// With within for scoping
import { within } from '@testing-library/react'
const list = screen.getByRole('list')
const items = within(list).getAllByRole('listitem')Testing User Interactions
Click Events
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function Counter() {
const [count, setCount] = React.useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
test('increments count when button clicked', async () => {
render(<Counter />)
const button = screen.getByRole('button', { name: /increment/i })
expect(screen.getByText(/count: 0/i)).toBeInTheDocument()
// Click button
await userEvent.click(button)
expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
// Click again
await userEvent.click(button)
expect(screen.getByText(/count: 2/i)).toBeInTheDocument()
})Form Inputs
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const handleSubmit = (e) => {
e.preventDefault()
onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button type="submit">Log In</button>
</form>
)
}
test('submits form with email and password', async () => {
const handleSubmit = jest.fn()
render(<LoginForm onSubmit={handleSubmit} />)
// Type in inputs
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await userEvent.type(emailInput, 'test@example.com')
await userEvent.type(passwordInput, 'password123')
// Submit form
const submitButton = screen.getByRole('button', { name: /log in/i })
await userEvent.click(submitButton)
// Assert
expect(handleSubmit).toHaveBeenCalledTimes(1)
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})Async Operations
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null)
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data)
setLoading(false)
})
}, [userId])
if (loading) return <div>Loading...</div>
return <div>User: {user.name}</div>
}
test('displays user after loading', async () => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John Doe' })
})
)
render(<UserProfile userId={1} />)
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for user to appear
const userName = await screen.findByText(/john doe/i)
expect(userName).toBeInTheDocument()
// Or use waitFor:
await waitFor(() => {
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
})
})Mocking
Mock Functions
// Create mock function
const mockFn = jest.fn()
// Mock with return value
const mockFn = jest.fn(() => 'returned value')
mockFn.mockReturnValue('returned value')
// Mock with different returns
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second')
// Mock async
mockFn.mockResolvedValue({ data: 'value' })
mockFn.mockRejectedValue(new Error('Failed'))
// Test the mock
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenLastCalledWith('lastArg')
// Get call data
expect(mockFn.mock.calls[0][0]).toBe('first argument')
expect(mockFn.mock.results[0].value).toBe('return value')Mock API Calls
// Option 1: Mock fetch globally
beforeEach(() => {
global.fetch = jest.fn()
})
afterEach(() => {
jest.resetAllMocks()
})
test('fetches users', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers
})
render(<UserList />)
const users = await screen.findAllByRole('listitem')
expect(users).toHaveLength(2)
})
// Option 2: Mock module
jest.mock('./api', () => ({
fetchUsers: jest.fn(() => Promise.resolve([
{ id: 1, name: 'Alice' }
]))
}))
// Option 3: Use MSW (Mock Service Worker) for more realistic mocking
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json([{ id: 1, name: 'Alice' }]))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())Common Testing Patterns
Testing Conditional Rendering
function Greeting({ isLoggedIn, username }) {
if (isLoggedIn) {
return <div>Welcome back, {username}!</div>
}
return <div>Please log in</div>
}
test('shows welcome message when logged in', () => {
render(<Greeting isLoggedIn={true} username="Alice" />)
expect(screen.getByText(/welcome back, alice/i)).toBeInTheDocument()
})
test('shows login prompt when not logged in', () => {
render(<Greeting isLoggedIn={false} />)
expect(screen.getByText(/please log in/i)).toBeInTheDocument()
expect(screen.queryByText(/welcome back/i)).not.toBeInTheDocument()
})Testing Lists
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
{todo.completed && <span>โ</span>}
</li>
))}
</ul>
)
}
test('renders all todos', () => {
const todos = [
{ id: 1, text: 'Buy milk', completed: false },
{ id: 2, text: 'Walk dog', completed: true }
]
render(<TodoList todos={todos} />)
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(2)
expect(screen.getByText(/buy milk/i)).toBeInTheDocument()
expect(screen.getByText(/walk dog/i)).toBeInTheDocument()
})
test('shows checkmark for completed todos', () => {
const todos = [
{ id: 1, text: 'Done task', completed: true }
]
render(<TodoList todos={todos} />)
expect(screen.getByText('โ')).toBeInTheDocument()
})Testing Custom Hooks
import { renderHook, act } from '@testing-library/react'
function useCounter(initialValue = 0) {
const [count, setCount] = React.useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
test('useCounter increments count', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
test('useCounter starts with initial value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('useCounter resets to initial value', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.increment()
})
expect(result.current.count).toBe(7)
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(5)
})Testing Best Practices
- Test Behavior, Not Implementation
// โ Bad - Tests implementation details
test('updates state when button clicked', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1) // Testing internal state
})
// โ
Good - Tests user-visible behavior
test('shows incremented count when button clicked', async () => {
render(<Counter />)
const button = screen.getByRole('button', { name: /increment/i })
await userEvent.click(button)
expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})
// Test what users see, not how it works internally!- Use Accessible Queries
// โ Bad - Non-semantic queries
screen.getByTestId('submit-button')
screen.getByClassName('btn-primary')
// โ
Good - Semantic, accessible queries
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)
screen.getByText(/welcome/i)
// Benefits:
// - Tests accessibility
// - More resilient to changes
// - Matches how users interact- Write Descriptive Test Names
// โ Bad - Vague test names
test('button works', () => {})
test('test 1', () => {})
// โ
Good - Clear, specific names
test('increments counter when increment button is clicked', () => {})
test('shows error message when form submitted with empty email', () => {})
test('disables submit button while loading', () => {})
// Good test names:
// - Describe what is being tested
// - Describe the action/condition
// - Describe expected outcome- Keep Tests Independent
// โ Bad - Tests depend on each other
let user
test('creates user', () => {
user = { id: 1, name: 'Alice' }
})
test('updates user', () => {
user.name = 'Bob' // Depends on previous test!
})
// โ
Good - Each test is independent
test('creates user', () => {
const user = { id: 1, name: 'Alice' }
expect(user.name).toBe('Alice')
})
test('updates user name', () => {
const user = { id: 1, name: 'Alice' }
user.name = 'Bob'
expect(user.name).toBe('Bob')
})
// Each test should work in isolation!- Don't Test External Libraries
// โ Bad - Testing React Router
test('Link navigates correctly', () => {
// Don't test that Link works - it's already tested!
})
// โ
Good - Test your code that uses the library
test('clicking logo navigates to home', async () => {
render(<Header />)
const logo = screen.getByRole('link', { name: /home/i })
expect(logo).toHaveAttribute('href', '/')
})
// Trust libraries are tested. Test your usage!Test Coverage
# Run tests with coverage
npm test -- --coverage
# Or add to package.json
"scripts": {
"test:coverage": "jest --coverage"
}
# Coverage report shows:
# - % of statements covered
# - % of branches covered
# - % of functions covered
# - % of lines covered
# View detailed report
open coverage/lcov-report/index.html
# Set coverage thresholds in jest.config.js
module.exports = {
coverageThreshold: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80
}
}
}
# Aim for 80%+ coverage, but 100% not always necessary!Key Takeaways
- Tests catch bugs early and enable confident refactoring
- Use Jest as test runner, React Testing Library for utilities
- Follow Arrange โ Act โ Assert pattern
- Use
getByRolefor accessible queries - Use
userEventfor realistic user interactions - Mock API calls with
jest.fn() - Test behavior users see, not implementation details
- Write descriptive test names
- Keep tests independent and isolated
- Use
findByfor async elements - Don't test external libraries
- Aim for 80%+ test coverage
- Run tests in watch mode during development
What's Next?
Excellent work! You now know how to test React components effectively with Jest and React Testing Library. You can write reliable, maintainable tests that give you confidence in your code.
In the final lesson, you'll put everything together by Building a Complete App:
- Multi-page application with routing
- State management across components
- API integration and data fetching
- Form handling and validation
- Error handling and loading states
- Performance optimization
- Component testing
Get ready to build a real-world React application from scratch! ๐