Frontend Testing Strategies with Jest and React Testing Library
Comprehensive guide to testing your React applications effectively.

Tests are confidence, not bureaucracy. A test suite that reflects how users interact with your app catches regressions before they ship, enables fearless refactoring, and documents expected behaviour better than any comment. This guide covers the practical patterns for testing React applications — what to test, how to structure it, and how to make tests maintainable rather than a burden.
The Testing Pyramid
Think of your tests in three tiers:
/\
/ \
/ E2E \ ← few, slow, expensive (Playwright, Cypress)
/--------\
/ Integration \ ← moderate, test user flows
/----------------\
/ Unit Tests \ ← many, fast, isolated
/--------------------\
- Unit tests — test a single function or component in isolation. Fast, deterministic, easy to write.
- Integration tests — test multiple components working together, often with a real (or close-to-real) backend.
- End-to-end tests — control a real browser. Highest confidence, highest cost.
Invest the most in the middle tier. Integration tests with React Testing Library give you high confidence without the fragility of E2E tests.
Setup
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom ts-jest
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
testEnvironment: "jsdom",
setupFilesAfterFramework: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1", // match tsconfig paths
"\\.(css|scss)$": "identity-obj-proxy",
},
transform: {
"^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }],
},
};
export default config;
// jest.setup.ts
import "@testing-library/jest-dom";
@testing-library/jest-dom adds custom matchers like toBeInTheDocument(), toHaveValue(), and toBeDisabled().
Core Philosophy: Test Behaviour, Not Implementation
The golden rule from React Testing Library: test what users see and do, not how your code is structured internally.
// Bad — tests implementation details
const wrapper = shallow(<Counter />);
expect(wrapper.state("count")).toBe(0);
wrapper.instance().increment();
expect(wrapper.state("count")).toBe(1);
// Good — tests what the user sees
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
userEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
Tests that target implementation details break when you refactor — even if behaviour is unchanged. Tests that target what users see survive refactors cleanly.
Querying the DOM
React Testing Library provides queries in priority order (highest to lowest):
| Priority | Query | Use when |
|---|---|---|
| 1st | getByRole |
Preferred — accessible by everyone |
| 2nd | getByLabelText |
Form inputs |
| 3rd | getByPlaceholderText |
Fallback for inputs |
| 4th | getByText |
Non-interactive text content |
| 5th | getByTestId |
Last resort — add data-testid when nothing else works |
// Prefer role queries
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email address" });
screen.getByRole("heading", { level: 1 });
screen.getByRole("navigation");
// Form inputs with labels
screen.getByLabelText("Email address");
// Avoid — brittle CSS class selectors
document.querySelector(".btn-primary");
getBy* throws if the element is not found. queryBy* returns null. findBy* returns a Promise and retries until found (use for async content).
Simulating User Interactions
Use @testing-library/user-event over fireEvent — it simulates real browser behaviour including focus, keyboard events, and pointer events:
import userEvent from "@testing-library/user-event";
test("form submits with user data", async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.type(screen.getByLabelText("Password"), "secret123");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "secret123",
});
});
Testing Async Components
For components that fetch data, use findBy* queries which wait for the element to appear:
test("displays user name after fetch", async () => {
// Mock the fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: "Alice", email: "alice@example.com" }),
});
render(<UserProfile userId="1" />);
// Loading state
expect(screen.getByRole("status")).toBeInTheDocument();
// Wait for data to appear
const name = await screen.findByText("Alice");
expect(name).toBeInTheDocument();
// Clean up
jest.restoreAllMocks();
});
Mocking
Mock Functions
const mockFn = jest.fn();
mockFn.mockReturnValue("result");
mockFn.mockResolvedValue({ data: "async result" });
mockFn.mockImplementation((x) => x * 2);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith("expected arg");
Mock Modules
// Mock an entire module
jest.mock("../lib/api", () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: "Alice" }),
fetchPosts: jest.fn().mockResolvedValue([]),
}));
// Access the mock in tests
import { fetchUser } from "../lib/api";
(fetchUser as jest.Mock).mockResolvedValueOnce({ id: 2, name: "Bob" });
Mock Timers
test("debounced search fires after delay", async () => {
jest.useFakeTimers();
const onSearch = jest.fn();
render(<SearchInput onSearch={onSearch} />);
await userEvent.type(screen.getByRole("searchbox"), "react");
expect(onSearch).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("react");
jest.useRealTimers();
});
Testing Custom Hooks
Use renderHook from @testing-library/react to test hooks in isolation:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "../hooks/useCounter";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
Snapshot Testing
Snapshot tests catch unintended UI changes. Use them sparingly — for stable, leaf-level components:
import { render } from "@testing-library/react";
test("Badge renders correctly", () => {
const { container } = render(<Badge variant="success" count={5} />);
expect(container).toMatchSnapshot();
});
When the snapshot fails, review the diff carefully. If the change is intentional, update with jest --updateSnapshot. If not, you caught a regression.
Avoid snapshots for large components — they become meaningless walls of JSX that no one reads when they break.
Testing with Context and Providers
Wrap components with required providers in tests using a custom render helper:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { Provider } from "react-redux";
import { store } from "@/store";
import { ThemeProvider } from "@/components/ThemeProvider";
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<ThemeProvider>{children}</ThemeProvider>
</Provider>
);
}
function customRender(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from "@testing-library/react";
export { customRender as render };
Import from test-utils instead of @testing-library/react:
import { render, screen } from "../test-utils";
test("cart shows item count", () => {
render(<CartButton />);
expect(screen.getByText("0 items")).toBeInTheDocument();
});
Accessibility Testing
Combine React Testing Library’s accessible queries with jest-axe for automated accessibility checks:
npm install -D jest-axe @types/jest-axe
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
test("LoginForm has no accessibility violations", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

Code Coverage
# Generate a coverage report
npx jest --coverage
# Set thresholds in jest.config.ts
const config: Config = {
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
},
},
};
Coverage measures which lines ran, not whether your tests assert the right things. 80% coverage with meaningful assertions beats 100% with expect(true).toBe(true). Focus on covering the paths users actually take.
CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test -- --ci --coverage --maxWorkers=2
- uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
The --ci flag prevents interactive mode and fails if snapshots are out of date.
What Makes a Good Test
- Tests one behaviour — when it fails, you know exactly what broke.
- Reads like a sentence —
test("shows error when email is invalid")beatstest("test1"). - Doesn’t test implementation — renaming a state variable shouldn’t break tests.
- Sets up its own data — no shared mutable test state between tests.
- Is fast — slow tests run less often; tests run less often catch less.
Closing Thoughts
A test suite built with React Testing Library’s approach — querying by role, simulating user events, asserting on what the user sees — ages far better than one built around implementation details. Write tests that document what your app does, not how it does it. When you come back six months later to refactor, the tests should help you, not fight you.
About the author
The Plake Team
Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.
Read moreKeep reading








