Introduction to Jest & Testing Library
Jest and Testing Library represent the modern standard for testing React applications. Together, they provide a powerful, developer-friendly ecosystem for writing maintainable tests that focus on user behavior rather than implementation details. For JavaScript unit testing alternatives, consider Mocha and Chai which offer similar flexibility with different assertion styles.
Jest is a comprehensive JavaScript testing framework developed by Facebook, featuring built-in test runners, assertion library, mocking capabilities, and code coverage tools. React Testing Library (part of the Testing Library family) encourages testing components the way users interact with them, promoting better testing practices and more resilient test suites.
Why Choose Jest & Testing Library?
Key Advantages:
- Zero Configuration: Works out-of-the-box with Create React App
- User-Centric Testing: Tests focus on user behavior, not implementation
- Fast Execution: Parallel test execution with intelligent caching
- Rich Ecosystem: Extensive matchers, utilities, and community plugins
- Accessibility Focus: Built-in accessibility testing capabilities
- Excellent DX: Clear error messages and helpful debugging tools
- Universal Support: Works with React, Vue, Angular, Svelte, and vanilla JS
Getting Started with Jest
Installation and Setup
# For new React projects (Jest included)
npx create-react-app my-app
# For existing projects
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
# TypeScript support
npm install --save-dev @types/jest
Jest Configuration
// jest.config.js
module.exports = {
// Test environment
testEnvironment: 'jsdom',
// Setup files
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Module paths
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx,ts,tsx}',
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// Transform files
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
// Test match patterns
testMatch: [
'**/__tests__/**/*.(test|spec).(js|jsx|ts|tsx)',
'**/*.(test|spec).(js|jsx|ts|tsx)',
],
};
Setup File
// src/setupTests.js
import '@testing-library/jest-dom';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
Core Jest Concepts
Basic Test Structure
// sum.js
export function sum(a, b) {
return a + b;
}
// sum.test.js
import { sum } from './sum';
describe('sum function', () => {
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('adds negative numbers correctly', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('handles zero', () => {
expect(sum(0, 5)).toBe(5);
});
});
Jest Matchers
describe('Jest matchers', () => {
// Equality
test('toBe vs toEqual', () => {
const obj = { name: 'John' };
expect(obj).toEqual({ name: 'John' }); // Deep equality
expect(obj).not.toBe({ name: 'John' }); // Reference equality
});
// Truthiness
test('truthiness matchers', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(0).toBeFalsy();
});
// Numbers
test('number matchers', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
// Strings
test('string matchers', () => {
expect('hello world').toMatch(/world/);
expect('hello').not.toMatch(/goodbye/);
expect('team').toContain('tea');
});
// Arrays and iterables
test('array matchers', () => {
const fruits = ['apple', 'banana', 'orange'];
expect(fruits).toContain('banana');
expect(fruits).toHaveLength(3);
expect(fruits).toEqual(expect.arrayContaining(['apple', 'banana']));
});
// Objects
test('object matchers', () => {
const user = {
name: 'John',
age: 30,
email: 'john@example.com',
};
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('age', 30);
expect(user).toMatchObject({ name: 'John' });
});
// Exceptions
test('exception matchers', () => {
function throwError() {
throw new Error('Something went wrong');
}
expect(throwError).toThrow();
expect(throwError).toThrow('Something went wrong');
expect(throwError).toThrow(Error);
});
});
Asynchronous Testing
// Promises
test('fetches user data', () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe('John Doe');
});
});
// Async/Await
test('fetches user data with async/await', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John Doe');
});
// Resolves/Rejects
test('user fetch resolves', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name');
});
test('user fetch rejects with error', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
Setup and Teardown
describe('Database operations', () => {
let db;
// Runs before all tests in this block
beforeAll(async () => {
db = await initDatabase();
});
// Runs before each test
beforeEach(async () => {
await db.clear();
});
// Runs after each test
afterEach(async () => {
await db.cleanup();
});
// Runs after all tests
afterAll(async () => {
await db.close();
});
test('inserts user', async () => {
await db.insert({ name: 'John' });
const users = await db.findAll();
expect(users).toHaveLength(1);
});
test('updates user', async () => {
await db.insert({ id: 1, name: 'John' });
await db.update(1, { name: 'Jane' });
const user = await db.findById(1);
expect(user.name).toBe('Jane');
});
});
React Testing Library Fundamentals
Component Testing Basics
// Button.jsx
export function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button component', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Click me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
Querying Elements
Query Priority (Recommended Order):
- Accessible by Everyone:
getByRole
,getByLabelText
,getByPlaceholderText
,getByText
- Semantic Queries:
getByAltText
,getByTitle
- Test IDs:
getByTestId
(last resort)
import { render, screen, within } from '@testing-library/react';
describe('Query methods', () => {
test('getBy queries', () => {
render(<LoginForm />);
// By role (BEST)
const button = screen.getByRole('button', { name: /log in/i });
// By label text
const emailInput = screen.getByLabelText(/email/i);
// By placeholder
const searchInput = screen.getByPlaceholderText(/search/i);
// By text content
const heading = screen.getByText(/welcome/i);
// By test ID (LAST RESORT)
const element = screen.getByTestId('custom-element');
expect(button).toBeInTheDocument();
});
test('queryBy for elements that might not exist', () => {
render(<Notification show={false} />);
// Returns null if not found (no error thrown)
const message = screen.queryByText(/notification/i);
expect(message).not.toBeInTheDocument();
});
test('findBy for async elements', async () => {
render(<AsyncComponent />);
// Waits for element to appear (returns promise)
const data = await screen.findByText(/loaded data/i);
expect(data).toBeInTheDocument();
});
test('getAllBy for multiple elements', () => {
render(<TodoList items={['Buy milk', 'Walk dog', 'Write code']} />);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
});
test('within for scoped queries', () => {
render(<UserCard name="John" email="john@example.com" />);
const card = screen.getByRole('article');
const nameInCard = within(card).getByText(/john/i);
expect(nameInCard).toBeInTheDocument();
});
});
User Interactions
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('user interactions', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Type into input
const nameInput = screen.getByLabelText(/name/i);
await user.type(nameInput, 'John Doe');
expect(nameInput).toHaveValue('John Doe');
// Clear input
await user.clear(nameInput);
expect(nameInput).toHaveValue('');
// Click button
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
// Double click
await user.dblClick(submitButton);
// Select option
const select = screen.getByLabelText(/country/i);
await user.selectOptions(select, 'USA');
expect(select).toHaveValue('USA');
// Check checkbox
const checkbox = screen.getByRole('checkbox', { name: /agree/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
// Upload file
const fileInput = screen.getByLabelText(/upload/i);
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
await user.upload(fileInput, file);
expect(fileInput.files[0]).toBe(file);
// Tab navigation
await user.tab();
expect(screen.getByLabelText(/email/i)).toHaveFocus();
});
Advanced Testing Patterns
Testing Forms
// ContactForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
test('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<ContactForm onSubmit={onSubmit} />);
// Fill form
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello world');
// Submit
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify submission
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world',
});
});
});
test('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /submit/i }));
// Check for error messages
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
test('shows error for invalid email format', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
});
Testing with Context and State Management
For React Native applications, check our React Native testing guide for platform-specific considerations.
// Testing with Context
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { Button } from './Button';
function renderWithTheme(ui, { theme = 'light', ...options } = {}) {
return render(<ThemeProvider value={theme}>{ui}</ThemeProvider>, options);
}
test('renders with dark theme', () => {
renderWithTheme(<Button>Click me</Button>, { theme: 'dark' });
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-dark');
});
// Testing with Redux
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
function renderWithRedux(
ui,
{
preloadedState = {},
store = configureStore({ reducer: { user: userReducer }, preloadedState }),
...options
} = {}
) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return { store, ...render(ui, { wrapper: Wrapper, ...options }) };
}
test('displays user name from store', () => {
const preloadedState = {
user: { name: 'John Doe', isLoggedIn: true },
};
renderWithRedux(<UserProfile />, { preloadedState });
expect(screen.getByText(/john doe/i)).toBeInTheDocument();
});
Mocking API Calls
// Using MSW (Mock Service Worker)
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads and displays users', async () => {
render(<UserList />);
// Wait for loading to finish
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for users to be displayed
await waitFor(() => {
expect(screen.getByText(/john doe/i)).toBeInTheDocument();
});
expect(screen.getByText(/jane smith/i)).toBeInTheDocument();
});
test('handles server error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Internal server error' }));
})
);
render(<UserList />);
expect(await screen.findByText(/error loading users/i)).toBeInTheDocument();
});
Testing Custom Hooks
// useCounter.js
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Accessibility Testing
Accessibility is crucial for inclusive applications. For mobile-specific accessibility testing, see our mobile accessibility guide.
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility tests', () => {
test('LoginForm has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('button has proper aria-label', () => {
render(<IconButton icon="trash" aria-label="Delete item" />);
const button = screen.getByRole('button', { name: /delete item/i });
expect(button).toHaveAccessibleName('Delete item');
});
test('form inputs have associated labels', () => {
render(<SignupForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
test('image has alt text', () => {
render(<Avatar src="/avatar.jpg" alt="User profile picture" />);
const image = screen.getByAltText(/user profile picture/i);
expect(image).toBeInTheDocument();
});
});
Jest Mocking Strategies
Function Mocking
// Simple mock
const mockCallback = jest.fn();
mockCallback('test');
expect(mockCallback).toHaveBeenCalledWith('test');
// Mock implementation
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);
// Mock return values
const mock = jest.fn();
mock.mockReturnValue(42);
mock.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
// Mock resolved/rejected promises
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ data: 'success' });
asyncMock.mockRejectedValue(new Error('Failed'));
Module Mocking
// api.js
export const fetchUser = (id) => {
return fetch(`/api/users/${id}`).then((res) => res.json());
};
// component.test.js
import { fetchUser } from './api';
jest.mock('./api');
test('loads user data', async () => {
fetchUser.mockResolvedValue({ name: 'John', email: 'john@example.com' });
render(<UserProfile userId={1} />);
expect(await screen.findByText(/john/i)).toBeInTheDocument();
expect(fetchUser).toHaveBeenCalledWith(1);
});
Partial Module Mocking
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
formatDate: jest.fn(() => '2025-01-01'),
}));
Snapshot Testing
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
test('matches snapshot', () => {
const { container } = render(
<UserCard name="John Doe" email="john@example.com" role="Admin" />
);
expect(container.firstChild).toMatchSnapshot();
});
// Inline snapshots
test('renders user name', () => {
const { container } = render(<UserCard name="John Doe" />);
expect(container.textContent).toMatchInlineSnapshot(`"John Doe"`);
});
Code Coverage and Reporting
# Run tests with coverage
npm test -- --coverage --watchAll=false
# Coverage thresholds in package.json
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
CI/CD Integration
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage --watchAll=false
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Best Practices
1. Test User Behavior, Not Implementation
// ❌ Bad: Testing implementation details
test('calls handleSubmit when form is submitted', () => {
const handleSubmit = jest.fn();
const { getByRole } = render(<Form onSubmit={handleSubmit} />);
// Testing prop directly
});
// ✅ Good: Testing user interaction
test('submits form when user clicks submit button', async () => {
const user = userEvent.setup();
render(<Form />);
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/success/i)).toBeInTheDocument();
});
2. Use Accessible Queries
// ❌ Avoid: Using test IDs
screen.getByTestId('submit-button');
// ✅ Prefer: Using accessible queries
screen.getByRole('button', { name: /submit/i });
3. Avoid Testing Library Internals
// ❌ Bad: Testing state
expect(component.state.isLoading).toBe(false);
// ✅ Good: Testing rendered output
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
Comparison with Other Tools
For E2E testing alternatives, explore Playwright and Cypress.
Feature | Jest + Testing Library | Enzyme | Cypress Component |
---|---|---|---|
Focus | User behavior | Implementation | E2E + Components |
Learning Curve | Low | Medium | Medium |
Speed | Fast | Fast | Slower |
Real Browser | No (JSDOM) | No | Yes |
Accessibility | Excellent | Limited | Good |
Community | Very Large | Declining | Growing |
Conclusion
Jest and Testing Library have become the de facto standard for modern React testing due to their focus on testing user behavior, excellent developer experience, and comprehensive feature set. By encouraging tests that interact with components as users would, they help create more maintainable and resilient test suites.
Jest & Testing Library are Perfect For:
- React applications of any size
- Teams prioritizing accessibility
- Projects requiring fast test execution
- Developers new to testing
- Component library development
Next Steps
- Install Jest and Testing Library in your project
- Start with simple component tests
- Learn accessible query methods
- Practice testing user interactions
- Add coverage reporting to your CI/CD
- Explore MSW for API mocking
Focus on testing what users see and do, and your test suite will remain valuable as your application evolves.