Contents

Chapter 1

Frontend Testing Strategy Guide

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.

Table of Contents


The Testing Pyramid (And Why It's Wrong)

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:

  • A unit test that verifies formatCurrency(1000) returns "$10.00" gives you confidence in a pure function. Good.
  • But a unit test that verifies a component renders a
    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:

LayerToolsWhat to testROI
Static AnalysisTypeScript, ESLintType errors, lint violationsVery high (free confidence)
Unit TestsVitestPure functions, hooks, utilitiesHigh for logic-heavy code
Integration TestsVitest + Testing LibraryComponent compositions, user flowsHighest for UI
E2E TestsPlaywrightCritical user journeys, cross-page flowsHigh 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 thisDon'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 valuesRedux action creators (they're trivial)
Schema validation rulesThat 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 thisDon't test this
User types in search → sees filtered resultsThat useState updates state
User submits form → validation errors showThat CSS makes the error red
User clicks sort → table rows reorderInternal sort algorithm (unit test that)
Modal opens → focus is trapped insideThat element exists in the DOM
API error → error message is displayedNetwork 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 thisDon't test this
Signup → email verification → loginEvery possible form validation error
Checkout → payment → confirmationThat the total formats correctly (unit test)
Keyboard navigation through the appIndividual component keyboard handling (integration test)
Browser back/forward navigationComponent rendering (integration test)
File upload works end-to-endFile 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

tsx
it("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

tsx
beforeEach(() => 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"));
});
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 thisDon'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 variablesInternal 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:

MetricTargetWhy
Statements80%Ensures most code paths are exercised
Branches75%Catches untested if/else/switch paths
Functions80%Ensures public API surface is tested
Lines80%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