A comprehensive guide to building a testing strategy for React applications. This isn't about which assertion library to pick -- it's about knowing what to test at which layer, when to invest in tests, and how to avoid the traps that make test suites a burden instead of a safety net.
The traditional testing pyramid says: many unit tests, fewer integration tests, very few E2E tests. For frontend applications, this is misleading.
Traditional Pyramid Frontend Reality (Testing Trophy)
/\ ___
/E2E\ /E2E\
/------\ /------\
/Integr- \ /Integration\
/ ation \ / (most value) \
/------------\ /----------------\
/ Unit Tests \ / Unit Tests \
/_______________\ /____________________\
| Static Analysis |
|____________________|
Why the pyramid fails for frontend:
formatCurrency(1000) returns "$10.00" gives you confidence in a pure function. Good. with the right class names? That's testing implementation details, and it breaks whenever you refactor markup.- Integration tests (multiple components working together, with mocked APIs) give you the most confidence per test for UI code.
The Testing Trophy
Kent C. Dodds' Testing Trophy rebalances the pyramid for frontend:
Layer Tools What to test ROI Static Analysis TypeScript, ESLint Type errors, lint violations Very high (free confidence) Unit Tests Vitest Pure functions, hooks, utilities High for logic-heavy code Integration Tests Vitest + Testing Library Component compositions, user flows Highest for UI E2E Tests Playwright Critical user journeys, cross-page flows High but expensive to maintain
Rule of thumb: If a test doesn't increase your confidence that the app works for users, delete it.
What to Test at Each Layer
Static Analysis (TypeScript + ESLint)
Catches 40-70% of bugs before you even run the code.
- Type mismatches between components (wrong props, missing required props)
- Unused variables and dead code
- Import errors
- Exhaustive switch/case checks
- React hook rule violations
Cost: Near-zero. It runs in your editor.
Unit Tests (Vitest)
Best for code with pure logic that's independent of the DOM.
Test this Don't test this formatCurrency(1099) → "$10.99"Component renders a with class "price" useDebounce returns value after delayComponent's internal state management sortByDate sorts correctlyThat Array.sort works (it does) Custom hook returns correct values Redux action creators (they're trivial) Schema validation rules That Zod/Yup exists and works
See: tests/unit/useDebounce.test.ts for a model unit test.
Integration Tests (Vitest + Testing Library)
Best for verifying that components work together correctly.
Test this Don't test this User types in search → sees filtered results That useState updates state User submits form → validation errors show That CSS makes the error red User clicks sort → table rows reorder Internal sort algorithm (unit test that) Modal opens → focus is trapped inside That element exists in the DOM API error → error message is displayed Network request headers
See: tests/integration/search-flow.test.tsx for a model integration test.
E2E Tests (Playwright)
Best for critical user journeys that cross page boundaries or involve real browser behavior.
Test this Don't test this Signup → email verification → login Every possible form validation error Checkout → payment → confirmation That the total formats correctly (unit test) Keyboard navigation through the app Individual component keyboard handling (integration test) Browser back/forward navigation Component rendering (integration test) File upload works end-to-end File size validation (unit test)
See: tests/e2e/search.spec.ts for a model E2E test with Page Object Model.
Component Testing Decision Tree
Use this to decide how to test a specific component:
Is it a pure utility function?
├── YES → Unit test (Vitest, no DOM)
└── NO → Is it a presentational component (just props → JSX)?
├── YES → Does it have interesting visual states?
│ ├── YES → Storybook story + visual regression
│ └── NO → Skip (TypeScript covers it)
└── NO → Does it manage state or side effects?
├── YES → Integration test (render with Testing Library)
│ └── Does it call APIs?
│ ├── YES → Mock the API, test the UI behavior
│ └── NO → Test state transitions via user interactions
└── NO → Does it compose multiple components?
├── YES → Integration test (render the composition)
└── NO → Probably doesn't need its own test file
Patterns That Pay Off
1. Test User Behavior, Not Implementation
tsx// BAD: Tests implementation details (fragile)
it("sets isOpen state to true when button is clicked", () => {
const { result } = renderHook(() => useModal());
act(() => result.current.open());
expect(result.current.isOpen).toBe(true); // Who cares about the state?
});
// GOOD: Tests what the user sees (resilient)
it("shows the modal content when the trigger is clicked", async () => {
const user = userEvent.setup();
render(<ModalWithTrigger />);
await user.click(screen.getByRole("button", { name: "Open" }));
expect(screen.getByRole("dialog")).toBeVisible();
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
2. Use Role Queries Over Test IDs
tsx// AVOID: data-testid couples tests to implementation
screen.getByTestId("submit-button");
// PREFER: role queries test what the user/screen reader sees
screen.getByRole("button", { name: "Submit" });
// ALSO GOOD: label queries (for form fields)
screen.getByLabelText("Email address");
// ALSO GOOD: text queries (for content)
screen.getByText("No results found");
Query priority (from Testing Library docs):
1. getByRole — accessible to everyone
2. getByLabelText — form fields
3. getByPlaceholderText — fallback for unlabeled inputs
4. getByText — visible text content
5. getByDisplayValue — current input value
6. getByAltText — images
7. getByTitle — title attribute (last resort)
8. getByTestId — only when nothing else works
3. Create Custom Render Wrappers
tsx// Instead of wrapping every test with providers manually:
render(
<QueryClientProvider client={testClient}>
<MemoryRouter>
<ThemeProvider>
<Component />
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
);
// Create a custom render (see tests/utils/render-helpers.tsx):
import { render } from "../utils/render-helpers";
render(<Component />); // All providers included automatically
4. Use Factory Functions for Test Data
tsx// Instead of copying giant object literals:
const user = {
id: "1", name: "Alice", email: "alice@example.com",
role: "admin", status: "active", department: "engineering",
// ...20 more fields
};
// Use factories that generate defaults with overrides:
const user = createMockUser({ role: "admin" });
const users = createMockUsers(10, { department: "engineering" });
See: tests/utils/mocks.ts for factory function patterns.
5. Arrange-Act-Assert Structure
tsxit("filters the table when searching", async () => {
// ARRANGE: set up the component and test data
const user = userEvent.setup();
render(<SearchableTable data={testData} />);
// ACT: simulate user behavior
await user.type(screen.getByRole("searchbox"), "react");
// ASSERT: verify the expected outcome
expect(screen.getByText("React Testing Library")).toBeInTheDocument();
expect(screen.queryByText("Vue Test Utils")).not.toBeInTheDocument();
});
Anti-Patterns That Hurt
1. Snapshot Tests for Components
tsx// DON'T: Snapshot tests for UI components
it("matches snapshot", () => {
const { container } = render(<Header />);
expect(container).toMatchSnapshot();
});
// Why bad: snapshots break on every markup change, developers
// blindly update them, and they don't catch behavioral bugs.
When snapshots ARE useful: serializable data structures (API responses, config objects).
2. Testing Library Implementation Details
tsx// DON'T: Test internal state
const { result } = renderHook(() => useState(0));
expect(result.current[0]).toBe(0);
// DON'T: Test CSS classes
expect(element).toHaveClass("bg-red-500");
// DON'T: Test DOM structure
expect(container.querySelector("div > span.error")).toBeTruthy();
3. Excessive Mocking
tsx// DON'T: Mock everything — you're testing the mocks, not the code
vi.mock("../components/Button");
vi.mock("../components/Input");
vi.mock("../hooks/useForm");
vi.mock("../utils/validation");
// What's left to test? Nothing real.
// DO: Mock at the boundary (API calls, browser APIs)
vi.mock("../api/userService"); // Mock the API, test the component
4. waitFor Wrapping Synchronous Assertions
tsx// DON'T: waitFor for things that are already there
await waitFor(() => {
expect(screen.getByText("Hello")).toBeInTheDocument();
});
// DO: Only use waitFor for async state changes
await waitFor(() => {
expect(screen.getByText("Loaded data")).toBeInTheDocument();
});
Testing Async Behavior
Fake Timers for Debounced Behavior
tsxbeforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("debounces the search", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<SearchInput onSearch={onSearch} debounceMs={300} />);
await user.type(input, "query");
vi.advanceTimersByTime(300);
await waitFor(() => expect(onSearch).toHaveBeenCalledWith("query"));
});
MSW for API Mocking (Recommended)
tsx// handlers.ts — define mock API responses
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("https://api.example.com/users", () => {
return HttpResponse.json([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
]);
}),
http.get("https://api.example.com/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Alice" });
}),
http.post("https://api.example.com/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "3", ...body }, { status: 201 });
}),
];
Testing Accessibility
Automated Checks
tsx// In unit/integration tests with jest-axe:
import { axe } from "jest-axe";
it("has no accessibility violations", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// In E2E tests with @axe-core/playwright:
import AxeBuilder from "@axe-core/playwright";
test("page is accessible", async ({ page }) => {
await page.goto("/login");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Manual Checks (Can't Automate These)
- Tab order makes logical sense
- Screen reader announces content correctly
- Color contrast passes WCAG AA
- Focus indicators are visible
- Error messages are associated with inputs (aria-describedby)
When to Mock (and When Not To)
Mock this Don't mock this API calls (fetch, axios) Child components Browser APIs (IntersectionObserver, ResizeObserver) Utility functions you wrote Third-party services (analytics, auth) React hooks (use integration tests) Environment variables Internal state management Date/time (use fake timers) CSS (use visual tests)
The Mocking Rule: Mock at the boundary between your code and the outside world. Everything inside the boundary should be tested as-is.
Coverage Goals
Don't chase 100% coverage. Instead, set meaningful thresholds:
Metric Target Why Statements 80% Ensures most code paths are exercised Branches 75% Catches untested if/else/switch paths Functions 80% Ensures public API surface is tested Lines 80% Balanced with branch coverage
What to exclude from coverage:
- Type-only files (
.d.ts) - Barrel exports (
index.ts) - Storybook stories
- Configuration files
- Generated code
High coverage ≠ good tests. You can have 100% coverage with zero assertions. Focus on testing behavior, not hitting lines.
Test Organization
tests/
├── utils/
│ ├── render-helpers.tsx # Custom render with providers
│ ├── mocks.ts # Factory functions for test data
│ └── fixtures.ts # Static fixture data
├── unit/
│ ├── formatters.test.ts # Pure function tests
│ └── useDebounce.test.ts # Hook tests
├── integration/
│ ├── search-flow.test.tsx # Multi-component flow
│ └── checkout.test.tsx # User journey
├── e2e/
│ ├── playwright.config.ts # Playwright configuration
│ ├── search.spec.ts # E2E search workflow
│ └── auth.spec.ts # E2E auth workflow
Naming conventions:
- Unit/integration:
ComponentName.test.tsx or hook-name.test.ts - E2E:
feature-name.spec.ts - Test descriptions: "does X when Y" (e.g., "shows error when email is invalid")
Performance Tips
1. Use vi.useFakeTimers() for debounced/throttled tests. Don't use real setTimeout — it slows down the suite.
2. Mock heavy dependencies (charting libraries, PDF generators) that don't need testing.
3. Use test.concurrent for tests that don't share state.
4. Run coverage only in CI, not during development (it adds ~30% overhead).
5. Split E2E by priority. Run critical paths on every PR, full suite nightly.
6. Use --changed flag during development to only test affected files:
bash npx vitest --changed
7. Parallelize CI — run unit, integration, and E2E tests as separate jobs.
*This guide is part of the Frontend Testing Toolkit.*
Part of Frontend Developer Pro
Frontend Testing Toolkit v1.0.0 — Free Preview