Skip to main content
Engineering Reference · 2026

Frontend Testing Strategy in 2026

A production-grade handbook for testing enterprise React and TypeScript applications. Covers unit testing with Vitest, React Testing Library, Playwright E2E, mocking strategies, accessibility testing, flaky test remediation, and scalable CI/CD integration.

~55 min readIntermediate to AdvancedVitest · RTL · Playwright · 2026
01 / Introduction

Why Testing Strategy Is an Architectural Decision

Testing is not something you add to a project after the features work. It is an architectural investment that determines how confidently and how quickly a team can ship changes over the lifetime of a codebase. A team with no tests ships features fast until they stop shipping features fast: the day a regression breaks production and nobody can tell when it was introduced. A team with the wrong tests invests time maintaining brittle suites that give false confidence and slow delivery down without catching real problems.

The goal of testing is not coverage. It is confidence: the ability to refactor, upgrade dependencies, add features, and deploy on Friday without a pit in your stomach. Coverage is a proxy for confidence, and a dangerously unreliable one when tests are testing the wrong things.

The right framing:ask “Does this test give me confidence that the feature works for a user?” not “Does this test increase my coverage percentage?” These produce fundamentally different test suites. The first produces tests that survive refactors. The second produces tests that break every time you rename a variable.

What a Good Testing Strategy Covers

  • Unit tests for pure logic, utility functions, custom hooks, and any code with complex branching that benefits from isolated verification.
  • Integration tests for how components work together with their data, rendering, and user interaction. This is where React Testing Library excels.
  • End-to-end tests for critical user journeys through the real application in a real browser. Playwright in 2026 is the standard.
  • Accessibility tests embedded at the integration layer, catching WCAG violations before they reach production.
  • Visual regression tests for UI components that should not change appearance unexpectedly across releases.
02 / Testing Pyramid and Trophy

The Testing Pyramid, the Testing Trophy, and What Actually Works

The classic Testing Pyramid (unit at the base, integration in the middle, E2E at the top) was designed for backend systems with well-defined units of business logic. For React frontends, it produces the wrong kind of tests: thousands of tightly-coupled unit tests that break when you rename a prop, while real rendering bugs slip through.

Kent C. Dodds proposed the Testing Trophy as a better model for frontend work. It de-emphasises unit tests at the bottom and puts integration tests at the widest point, reflecting that most valuable frontend testing happens at the component interaction level, not the isolated function level.

Trophy Shape in Practice

  • Static analysis (base): TypeScript, ESLint, and Prettier catch whole classes of bugs before any test runs. This is your cheapest and highest-ROI layer.
  • Unit tests (narrow layer): Pure utility functions, complex state machines, date manipulation, currency formatting. Components only if the component has significant internal logic that is not visible at the integration level.
  • Integration tests (widest layer): User-facing behaviour of components, forms, pages. Render the real component tree, interact with it, assert on what the user sees. This is where most of your test budget goes.
  • End-to-end tests (top): Critical user journeys only: login, checkout, account creation, core value proposition. Do not replicate integration tests here. E2E tests are expensive to write, slow to run, and sensitive to flakiness.
The 2026 ratio for a mature React codebase: aim for roughly 60% integration tests, 25% unit tests, 10% E2E, and 5% visual regression. These numbers shift based on the product: a data-heavy dashboard with complex calculations needs more unit tests; a marketing site needs more E2E coverage of key conversion flows.
03 / Unit Testing with Vitest

Unit Testing with Vitest

Vitest is the test runner of choice for Vite-based React and TypeScript projects in 2026. It reuses the Vite configuration, supports native ES modules without transformation overhead, runs tests concurrently with worker threads, and provides a Jest-compatible API so migration from Jest is straightforward.

Vitest vs. Jest in 2026

FeatureVitestJest
ESM supportNative, no transformRequires @jest/globals or transform config
TypeScriptNative via Vite pipelineRequires ts-jest or babel-jest
SpeedFaster (Vite HMR, worker threads)Slower cold start, no HMR in watch mode
Config reuseShares vite.config.tsSeparate jest.config.ts required
API compatibilityMostly Jest-compatibleThe reference API
Next.js 15 supportNeeds jsdom + mocks for next/*Same: needs jest-environment-jsdom + mocks
tsvitest.config.ts: recommended setup
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,                // vi, expect, describe etc. available globally
    setupFiles: ["./vitest.setup.ts"],
    include: ["**/__tests__/**/*.{ts,tsx}", "**/*.{spec,test}.{ts,tsx}"],
    exclude: ["**/node_modules/**", "**/.next/**", "**/e2e/**"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
  resolve: {
    alias: {
      "@": resolve(__dirname, "./"),
    },
  },
});
tsTesting a pure utility function
// lib/formatCurrency.ts
export function formatCurrency(
  amount: number,
  currency = "AUD",
  locale = "en-AU"
): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
  }).format(amount);
}

// __tests__/lib/formatCurrency.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency } from "@/lib/formatCurrency";

describe("formatCurrency", () => {
  it("formats Australian dollars correctly", () => {
    expect(formatCurrency(1234.5)).toBe("$1,234.50");
  });

  it("formats euros with the correct symbol", () => {
    expect(formatCurrency(99.99, "EUR", "de-DE")).toMatch(/99,99/);
  });

  it("handles zero amount", () => {
    expect(formatCurrency(0)).toBe("$0.00");
  });

  it("handles negative amounts", () => {
    expect(formatCurrency(-50)).toMatch(/-/);
  });
});
tsTesting a custom React hook with renderHook
// hooks/useCounter.ts
import { useState, useCallback } from "react";

export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initial), [initial]);
  return { count, increment, decrement, reset };
}

// __tests__/hooks/useCounter.test.ts
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";

describe("useCounter", () => {
  it("initialises with the default value", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("initialises with a custom value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increments on each call", () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    act(() => result.current.increment());
    expect(result.current.count).toBe(2);
  });

  it("resets to the initial value", () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});
04 / React Testing Library

Integration Testing with React Testing Library

React Testing Library (RTL) is built around a single principle: tests should resemble how users interact with the software. It deliberately does not expose component internals (state, refs, lifecycle methods) and instead provides queries that mirror what a user sees and does: find elements by their visible text, label, role, or accessible name; interact with them using userEvent; and assert on the resulting DOM.

The guiding rule:if you cannot write a test without accessing internal state or props directly, your component may be too tightly coupled to its implementation. RTL's design pressure is intentional: it nudges you toward better component boundaries.

Query Priority

RTL provides multiple ways to find elements. The recommended order of preference mirrors what assistive technology uses:

  1. getByRole (highest priority): semantic roles like button, textbox, combobox,heading. This also verifies accessibility.
  2. getByLabelText: form inputs associated with a label. Enforces proper label associations.
  3. getByPlaceholderText: only when label is not available (fallback).
  4. getByText: for non-interactive elements with visible text content.
  5. getByTestId (lowest priority): use only when semantic queries are not possible. Never as the default.
tsxTesting a form: interaction and async assertions
// components/LoginForm.tsx
"use client";
import { useState } from "react";

export function LoginForm({ onLogin }: { onLogin: (email: string, password: string) => Promise<void> }) {
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    setLoading(true);
    setError(null);
    try {
      await onLogin(fd.get("email") as string, fd.get("password") as string);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Login failed");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" required />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? "Signing in..." : "Sign in"}
      </button>
    </form>
  );
}

// __tests__/components/LoginForm.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "@/components/LoginForm";

describe("LoginForm", () => {
  it("calls onLogin with email and password on submit", async () => {
    const user = userEvent.setup();
    const mockLogin = vi.fn().mockResolvedValue(undefined);

    render(<LoginForm onLogin={mockLogin} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Password"), "secret123");
    await user.click(screen.getByRole("button", { name: "Sign in" }));

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith("test@example.com", "secret123");
    });
  });

  it("shows an error message when login fails", async () => {
    const user = userEvent.setup();
    const mockLogin = vi.fn().mockRejectedValue(new Error("Invalid credentials"));

    render(<LoginForm onLogin={mockLogin} />);

    await user.type(screen.getByLabelText("Email"), "bad@example.com");
    await user.type(screen.getByLabelText("Password"), "wrongpass");
    await user.click(screen.getByRole("button", { name: "Sign in" }));

    await waitFor(() => {
      expect(screen.getByRole("alert")).toHaveTextContent("Invalid credentials");
    });
  });

  it("disables the button while loading", async () => {
    const user = userEvent.setup();
    let resolveLogin!: () => void;
    const mockLogin = vi.fn(
      () => new Promise<void>((res) => { resolveLogin = res; })
    );

    render(<LoginForm onLogin={mockLogin} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Password"), "secret");
    await user.click(screen.getByRole("button", { name: "Sign in" }));

    expect(screen.getByRole("button", { name: "Signing in..." })).toBeDisabled();
    resolveLogin();
  });
});
05 / Mocking Strategies

Mocking Strategies

Mocks are a tool, not a goal. The more you mock, the less your tests resemble reality. Every mock is a contract you maintain manually: if the real implementation changes and the mock does not, your tests pass but production breaks. Mock only when the alternative is impractical: network calls in unit tests, external services, heavy dependencies with no test-friendly interface.

vi.mock: Module-Level Mocking

tsMocking a module with vi.mock
// Mocking next/navigation for components that use useRouter or usePathname
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
  }),
  usePathname: vi.fn(() => "/"),
  useSearchParams: vi.fn(() => new URLSearchParams()),
}));

// Mocking a service module
vi.mock("@/lib/api/users", () => ({
  getUser: vi.fn(),
  updateUser: vi.fn(),
  deleteUser: vi.fn(),
}));

// In a test, override specific mock behaviour:
import { getUser } from "@/lib/api/users";

it("shows user data when fetch succeeds", async () => {
  vi.mocked(getUser).mockResolvedValue({
    id: "1",
    name: "Mahsa Mohajer",
    email: "mahsa@example.com",
  });

  render(<UserProfile userId="1" />);
  expect(await screen.findByText("Mahsa Mohajer")).toBeInTheDocument();
});

MSW: API Mocking at the Network Level

Mock Service Worker (MSW) intercepts network requests at the service worker level, meaning your components call fetch and receive a real response. The mock lives at the network boundary rather than inside your application code. This produces higher-confidence tests because the component code is unchanged: no injected mock functions, no special test paths.

tsMSW setup for Vitest and Playwright
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: "Mahsa Mohajer",
      role: "admin",
    });
  }),

  http.post("/api/auth/login", async ({ request }) => {
    const body = await request.json() as { email: string; password: string };

    if (body.password === "wrong") {
      return HttpResponse.json(
        { error: "Invalid credentials" },
        { status: 401 }
      );
    }

    return HttpResponse.json({ token: "mock-jwt-token", userId: "1" });
  }),

  http.get("/api/products", () => {
    return HttpResponse.json([
      { id: "1", name: "Product A", price: 99 },
      { id: "2", name: "Product B", price: 149 },
    ]);
  }),
];

// vitest.setup.ts (add to existing setup)
import { setupServer } from "msw/node";
import { handlers } from "./src/mocks/handlers";

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

When to Mock vs. When Not To

Mock these

  • Network requests (use MSW, not manual fetch mocks)
  • External service SDKs (Stripe, Twilio, Segment)
  • Browser APIs unavailable in jsdom (IntersectionObserver, ResizeObserver)
  • Next.js router in unit/integration tests
  • Date and time in determinism-sensitive tests (vi.useFakeTimers)

Do not mock these

  • Your own utility functions (test them directly)
  • React itself or React DOM
  • Simple child components (render the real thing)
  • State management libraries (Zustand, Redux work fine in tests)
  • CSS modules (stub with empty objects, never try to assert styles)
06 / Component Testing Patterns

Component Testing Patterns

Wrapping Components with Providers

Components that consume context (theme, auth, query client) need providers in tests. Create a reusable renderWithProviders wrapper rather than duplicating provider setup in every test file:

tsxCustom render with providers
// test-utils/renderWithProviders.tsx
import { ReactNode } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,        // fail fast in tests, no retry delays
        gcTime: Infinity,    // keep data in cache for the test duration
      },
    },
  });
}

interface WrapperProps {
  children: ReactNode;
  queryClient?: QueryClient;
}

function AllProviders({ children, queryClient }: WrapperProps) {
  const client = queryClient ?? createTestQueryClient();
  return (
    <QueryClientProvider client={client}>
      {children}
    </QueryClientProvider>
  );
}

export function renderWithProviders(
  ui: React.ReactElement,
  options?: Omit<RenderOptions, "wrapper"> & { queryClient?: QueryClient }
) {
  const { queryClient, ...renderOptions } = options ?? {};
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders queryClient={queryClient}>{children}</AllProviders>
    ),
    ...renderOptions,
  });
}

Testing Async Component Data

tsxTesting a component that fetches with TanStack Query
// __tests__/components/UserCard.test.tsx
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/test-utils/renderWithProviders";
import { UserCard } from "@/components/UserCard";
// MSW handler intercepts the /api/users/1 request (set up in vitest.setup.ts)

describe("UserCard", () => {
  it("shows user data after loading", async () => {
    renderWithProviders(<UserCard userId="1" />);

    // Assert loading state while the mock API responds
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    // Wait for the mock data to arrive and render
    expect(await screen.findByText("Mahsa Mohajer")).toBeInTheDocument();
    expect(screen.getByText("admin")).toBeInTheDocument();
  });

  it("shows an error state when the API fails", async () => {
    // Override the default MSW handler for this test
    server.use(
      http.get("/api/users/:id", () =>
        HttpResponse.json({ error: "Not found" }, { status: 404 })
      )
    );

    renderWithProviders(<UserCard userId="999" />);
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

Snapshot Testing: Use Sparingly

Snapshot tests capture the rendered output of a component and fail when it changes. They are useful for catching unintentional UI regressions in stable, low-churn components. They are counterproductive when used broadly: developers update snapshots automatically on every PR without reviewing the diff, making the tests meaningless.

Snapshot test rule of thumb: if you find yourself runningvitest --update-snapshots more than once a sprint, the component is changing too often for snapshots to provide meaningful protection. Switch to explicit assertions or Playwright visual regression tests instead.
07 / End-to-End with Playwright

End-to-End Testing with Playwright

Playwright is the standard E2E testing tool for React applications in 2026. It runs tests in real browsers (Chromium, Firefox, WebKit), supports TypeScript natively, has an exceptional auto-wait system that eliminates most flakiness, and includes a built-in test reporter, trace viewer, and screenshot capabilities. It has superseded Cypress for most new projects.

Setup and Configuration

bashInstall and initialize Playwright
yarn add -D @playwright/test
# npm install --save-dev @playwright/test

yarn playwright install       # downloads browser binaries
# npx playwright install

yarn playwright codegen http://localhost:3000  # record tests interactively
# npx playwright codegen http://localhost:3000
tsplaywright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,  // fail if .only left in accidentally
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ["html", { outputFolder: "playwright-report" }],
    ["list"],
  ],
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",    // capture trace on failure for debugging
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
  ],
  webServer: {
    command: "yarn dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Page Object Model

The Page Object Model (POM) encapsulates UI interaction details behind methods, making tests readable and keeping locators in one place. When a UI element moves or changes label, you update the POM instead of every test.

tsPage Object Model for a login flow
// e2e/pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorAlert: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton = page.getByRole("button", { name: "Sign in" });
    this.errorAlert = page.getByRole("alert");
  }

  async goto() {
    await this.page.goto("/login");
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test.describe("Authentication", () => {
  test("redirects to dashboard on successful login", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login("user@example.com", "valid-password");

    await expect(page).toHaveURL("/dashboard");
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  });

  test("shows error on invalid credentials", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login("user@example.com", "wrong-password");

    await expect(loginPage.errorAlert).toBeVisible();
    await expect(loginPage.errorAlert).toContainText("Invalid credentials");
  });

  test("persists session across page reload", async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login("user@example.com", "valid-password");
    await page.reload();

    await expect(page).toHaveURL("/dashboard");
  });
});

Playwright vs. Cypress in 2026

Playwright advantages

  • Runs in Chromium, Firefox, and WebKit natively
  • Auto-waits for elements to be actionable: far fewer explicit waits
  • Parallel test execution across workers by default
  • First-class TypeScript, no plugin needed
  • Trace viewer for debugging failures after the fact
  • No browser tab limitations or DOM origin restrictions

Cypress still wins at

  • Time-travel debugging in the Cypress GUI
  • Simpler component testing setup (Cypress CT)
  • Larger existing ecosystem and plugin library
  • Teams already invested in Cypress with large test suites
08 / Accessibility Testing

Accessibility Testing

Automated accessibility testing catches a meaningful but limited portion of WCAG violations: roughly 30-40% of issues can be detected programmatically. The rest require human judgement (does this flow make sense with a screen reader? is the focus order logical?). Automate what you can at the integration layer and invest in manual a11y reviews for critical journeys.

jest-axe: Integration Layer Accessibility

tsxRunning axe in RTL tests with jest-axe
yarn add -D jest-axe @types/jest-axe
# npm install --save-dev jest-axe @types/jest-axe

// __tests__/components/Modal.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Modal } from "@/components/Modal";

expect.extend(toHaveNoViolations);

describe("Modal accessibility", () => {
  it("has no axe violations when open", async () => {
    const { container } = render(
      <Modal isOpen title="Confirm deletion" onClose={() => {}}>
        <p>Are you sure you want to delete this item?</p>
        <button>Cancel</button>
        <button>Delete</button>
      </Modal>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it("focuses the heading when opened", async () => {
    const user = userEvent.setup();
    render(
      <div>
        <button>Open modal</button>
        <Modal isOpen title="Confirm deletion" onClose={() => {}}>
          <p>Content here</p>
        </Modal>
      </div>
    );

    // Verify focus is trapped within the modal
    await user.tab();
    expect(screen.getByRole("dialog")).toContainElement(document.activeElement);
  });
});

Playwright Accessibility Scanning

tsRunning axe-core in Playwright E2E tests
yarn add -D @axe-core/playwright
# npm install --save-dev @axe-core/playwright

// e2e/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("Accessibility audits", () => {
  test("homepage has no critical a11y violations", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test("login page has no a11y violations", async ({ page }) => {
    await page.goto("/login");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa"])
      .exclude("#third-party-widget")  // exclude known third-party issues
      .analyze();

    expect(results.violations).toEqual([]);
  });
});

What Automated Testing Cannot Catch

  • Focus order quality: axe confirms focus is reachable but not whether the focus order is logical for a keyboard user.
  • Screen reader announcement quality: axe checks that ARIA labels exist, not whether they communicate the right information.
  • Cognitive accessibility: complex flows, confusing language, and overwhelming information density are human problems.
  • Motion and animation sensitivity: automatic animation that ignores prefers-reduced-motion requires visual inspection.
09 / Flaky Test Remediation

Dealing with Flaky Tests

A flaky test is one that passes and fails non-deterministically without any code change. Flaky tests are more dangerous than no tests: they train engineers to ignore red builds, which means real failures get ignored too. Treating flakiness as a first-class defect is a discipline issue as much as a technical one.

Root Causes of Flakiness

Timing issues

  • Using setTimeout or arbitrary sleep instead of proper waitFor
  • Asserting before async operations complete
  • Race conditions between concurrent network requests
  • Animation timing dependencies in E2E tests

Shared state

  • Tests relying on order of execution
  • Global singletons not cleaned between tests
  • Module-level state in mocked modules
  • Database or localStorage state leaking between tests
tsAnti-pattern vs. fix: async assertions
// ANTI-PATTERN: arbitrary sleep
test("shows success message", async () => {
  render(<SubmitForm />);
  fireEvent.click(screen.getByRole("button", { name: "Submit" }));
  await new Promise((resolve) => setTimeout(resolve, 500)); // flaky!
  expect(screen.getByText("Success!")).toBeInTheDocument();
});

// FIX: use waitFor to poll until the assertion passes
test("shows success message", async () => {
  render(<SubmitForm />);
  await userEvent.click(screen.getByRole("button", { name: "Submit" }));

  // waitFor retries until the assertion passes or times out
  await waitFor(() => {
    expect(screen.getByText("Success!")).toBeInTheDocument();
  });
});

// FIX: findBy* queries are waitFor + getBy combined
test("shows success message", async () => {
  render(<SubmitForm />);
  await userEvent.click(screen.getByRole("button", { name: "Submit" }));

  // findByText automatically waits up to 1000ms (configurable)
  expect(await screen.findByText("Success!")).toBeInTheDocument();
});
tsPlaywright: leveraging auto-wait and stable locators
// ANTI-PATTERN: arbitrary waits in Playwright
test("button appears after load", async ({ page }) => {
  await page.goto("/dashboard");
  await page.waitForTimeout(2000);  // arbitrary sleep -- flaky
  await page.click("#submit-btn");
});

// FIX: Playwright auto-waits for elements to be visible and actionable
test("button appears after load", async ({ page }) => {
  await page.goto("/dashboard");
  // getByRole waits automatically until the button is present and clickable
  await page.getByRole("button", { name: "Submit" }).click();
});

// FIX: wait for a specific network request to complete
test("loads user data before interaction", async ({ page }) => {
  await page.goto("/profile");

  // Wait for the specific API call to finish before asserting
  await page.waitForResponse((response) =>
    response.url().includes("/api/users/me") && response.status() === 200
  );

  await expect(page.getByText("Mahsa Mohajer")).toBeVisible();
});

Quarantine and Triage Process

When a flaky test is identified, quarantine it immediately: move it to a separate suite that runs but does not fail the build. Track it in your issue tracker. Fix it within one sprint. A quarantined test that is never fixed is a deleted test with extra steps: remove it if it cannot be maintained.

10 / CI/CD Integration

Testing in CI/CD

Tests that do not run on every pull request are not part of your quality gate. A CI pipeline for a React project in 2026 should run type checking, linting, unit and integration tests, and E2E tests in parallel where possible, with clear failure reporting that tells engineers exactly what broke.

yaml.github/workflows/ci.yml: parallel test pipeline
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn
      - run: yarn install --immutable
      - run: yarn tsc --noEmit

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn
      - run: yarn install --immutable
      - run: yarn lint

  unit-integration:
    name: Unit and Integration Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn
      - run: yarn install --immutable
      - run: yarn test --run --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/

  e2e:
    name: E2E Tests (Shard ${{ matrix.shard }}/${{ strategy.job-total }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]  # run E2E in 4 parallel shards
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: yarn
      - run: yarn install --immutable
      - run: yarn playwright install --with-deps chromium
      - run: yarn build
      - run: yarn playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/

Pre-Push Hooks

Catching issues locally before a push avoids the CI wait loop. A pre-push hook that runs tsc --noEmit and the unit test suite (not E2E) gives fast feedback without blocking daily workflow:

bash.husky/pre-push
#!/bin/sh
# Type check
yarn tsc --noEmit || exit 1

# Unit and integration tests only (E2E runs in CI)
yarn test --run || exit 1
11 / Coverage and Quality Gates

Coverage and Quality Gates

Coverage measures which lines of code were executed during tests: it does not measure whether those tests verified anything meaningful. 100% coverage with tests that have no assertions is technically achievable and completely useless. Use coverage as a floor, not a ceiling. A healthy floor is 80% across branches, functions, lines, and statements for most production React codebases.

The coverage trap: teams that set coverage as a KPI quickly produce tests that hit lines without verifying behaviour. The result is high coverage numbers and zero confidence. Treat coverage as a tool for finding untested areas, not as a definition of quality.
tsvitest.config.ts: coverage thresholds as quality gates
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "json-summary", "html", "lcov"],
      include: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"],
      exclude: [
        "**/*.d.ts",
        "**/*.config.{ts,mjs}",
        "**/node_modules/**",
        "**/.next/**",
        "**/e2e/**",
        "**/__tests__/**",
        "**/vitest.setup.ts",
      ],
      thresholds: {
        // Build fails if coverage drops below these thresholds
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
        // Per-file thresholds for critical modules
        "lib/auth.ts": {
          branches: 95,
          functions: 100,
          lines: 95,
          statements: 95,
        },
      },
    },
  },
});

Coverage by Layer

LayerRecommended CoverageRationale
Utility functions90-100%Pure functions, easy to test exhaustively
Business logic hooks85-95%Complex state, important branches need verification
UI components70-85%Integration tests cover most paths; not every visual branch needs a test
Auth and security95-100%Every auth branch has security implications
Page components60-75%E2E covers critical paths; not worth unit-testing every layout detail
12 / Testing Trade-offs

Testing Trade-offs and ROI

Every test has a cost: time to write, time to run, time to maintain when the code changes. The question is not “should we test this?” but “what type of test gives us the most confidence for the least ongoing cost?”

Test TypeWrite TimeRun TimeMaintenanceConfidenceBest For
TypeScriptZero~3sVery lowHigh (type errors)Contracts, API shapes, prop types
Unit (Vitest)Low< 1ms/testMedium (refactor breaks)Medium (isolated)Pure functions, hooks, complex logic
Integration (RTL)Medium10-100ms/testLow (behaviour stable)High (user perspective)Component flows, form submission, UI state
E2E (Playwright)High5-60s/testHigh (UI changes break)Very high (real browser)Critical paths: login, checkout, core feature
Visual regressionMediumSlow (screenshots)High (intentional changes)High (pixel diff)Design system components, stable UI surfaces
The sweet spot for frontend confidence: invest heavily in integration tests (RTL with MSW), cover critical paths with a small E2E suite, and let TypeScript do the heavy lifting at the type layer. This combination gives excellent confidence with manageable maintenance cost.
13 / Enterprise Scale

Enterprise-Scale Testing Strategy

Testing strategy that works for a five-person team building a single app breaks down at enterprise scale: monorepos with dozens of packages, multiple teams writing tests in isolation, CI pipelines that take 40 minutes, and component libraries consumed by many applications each needing its own test coverage.

Monorepo Testing Architecture

tsVitest workspace for a monorepo
// vitest.workspace.ts (root of monorepo)
import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  // Each package has its own vitest config
  "packages/ui/vitest.config.ts",
  "packages/auth/vitest.config.ts",
  "packages/api-client/vitest.config.ts",
  // Apps inherit from root but add app-specific setup
  {
    extends: "apps/dashboard/vite.config.ts",
    test: {
      name: "dashboard",
      environment: "jsdom",
      setupFiles: ["apps/dashboard/vitest.setup.ts"],
      include: ["apps/dashboard/**/*.{spec,test}.{ts,tsx}"],
    },
  },
]);

// Run tests for the entire workspace:
// yarn vitest  (watch mode)
// yarn vitest --run  (CI mode)

// Run tests for one package only:
// yarn vitest --project=ui

Shared Test Utilities and Factories

tsTest factories for consistent fixture data
// packages/test-utils/src/factories/user.ts
import { faker } from "@faker-js/faker";

export interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "member" | "viewer";
  createdAt: Date;
}

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    role: "member",
    createdAt: faker.date.recent(),
    ...overrides,
  };
}

export function createUserList(count: number, overrides: Partial<User> = {}): User[] {
  return Array.from({ length: count }, () => createUser(overrides));
}

// Usage in any package test:
import { createUser, createUserList } from "@company/test-utils";

const adminUser = createUser({ role: "admin" });
const teamMembers = createUserList(5, { role: "member" });

Playwright Multi-Project Configuration

tsPlaywright projects for staging and production smoke tests
// playwright.config.ts
export default defineConfig({
  projects: [
    // Development: full test suite
    {
      name: "dev-chrome",
      use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:3000" },
      testMatch: "e2e/**/*.spec.ts",
    },
    // Staging: smoke tests only (fast, pre-deploy check)
    {
      name: "staging-smoke",
      use: {
        ...devices["Desktop Chrome"],
        baseURL: process.env.STAGING_URL ?? "https://staging.example.com",
      },
      testMatch: "e2e/smoke/**/*.spec.ts",
    },
    // Production: critical-path health checks post-deploy
    {
      name: "production-health",
      use: {
        ...devices["Desktop Chrome"],
        baseURL: "https://example.com",
      },
      testMatch: "e2e/health/**/*.spec.ts",
      retries: 3,
    },
  ],
});

Quality Gates at Scale

Per-PR gates

  • TypeScript compiles without errors
  • ESLint passes with zero warnings
  • Unit and integration tests pass
  • Coverage does not drop below thresholds
  • E2E smoke suite passes on staging

Per-release gates

  • Full E2E suite passes across all browser targets
  • Visual regression baseline comparison clean
  • Accessibility audit shows no new violations
  • Performance budget check: bundle size within limits
  • Production health checks pass post-deploy
14 / Anti-Patterns

Testing Anti-Patterns to Avoid

1. Testing Implementation Details

tsxAnti-pattern: testing internal state
// BAD: testing internal React state directly
import { useState } from "react";

// This test breaks on refactor even if user behaviour is unchanged
test("toggles isOpen state", () => {
  const { result } = renderHook(() => {
    const [isOpen, setIsOpen] = useState(false);
    return { isOpen, setIsOpen };
  });
  act(() => result.current.setIsOpen(true));
  expect(result.current.isOpen).toBe(true);  // Testing state, not behaviour
});

// GOOD: test what the user sees and does
test("opens the dropdown when trigger is clicked", async () => {
  const user = userEvent.setup();
  render(<Dropdown label="Options" items={["Edit", "Delete"]} />);

  await user.click(screen.getByRole("button", { name: "Options" }));

  expect(screen.getByRole("menu")).toBeVisible();
  expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
});

2. Over-Mocking (Testing Your Mocks)

When every dependency is mocked, you are testing that your component calls the mock correctly, not that it integrates correctly. A test suite with 80% mock coverage and no integration tests passes when the real implementations change in breaking ways.

3. Using E2E for Everything

E2E tests have a place: critical user journeys. Using them to test every form field, every error state, and every UI variant produces a suite that takes 30 minutes to run, breaks constantly, and developers start skipping. Reserve E2E for journeys only a real browser can verify: multi-tab interactions, service worker caching, OAuth redirects, file downloads.

4. Ignoring Test Isolation

tsAnti-pattern: shared state leaking between tests
// BAD: module-level shared state
const mockRouter = { push: vi.fn() };

vi.mock("next/navigation", () => ({ useRouter: () => mockRouter }));

test("navigates to profile on click", async () => {
  render(<NavButton />);
  await userEvent.click(screen.getByRole("button"));
  expect(mockRouter.push).toHaveBeenCalledWith("/profile");
});

test("navigates to settings on click", async () => {
  render(<AnotherButton />);
  await userEvent.click(screen.getByRole("button"));
  // FLAKY: mockRouter.push may have been called in the previous test
  expect(mockRouter.push).toHaveBeenCalledWith("/settings");
});

// GOOD: clear mocks between tests
afterEach(() => {
  vi.clearAllMocks();
});
// Or use beforeEach to recreate fresh mocks

5. Snapshot Testing Everything

Snapshot tests should be rare and deliberate. If your codebase has hundreds of snapshot files that are updated automatically on every styling change, they are providing zero value and significant noise. Delete them and write explicit assertions for the specific behaviour that matters.

The test quality signal:if a developer's first instinct on a failing test is to update the snapshot or retry the flaky test rather than investigate the failure, the test suite has lost the team's trust. That is the most expensive outcome: a suite nobody believes in.
15 / Without Next.js

Testing Strategy Without Next.js

Most of this guide applies equally to Next.js and plain Vite React projects. Vitest, React Testing Library, Playwright, MSW, and jest-axe all work identically in either context. The meaningful differences are in how you configure the test environment and what you need to mock.

What Changes Without Next.js

ConcernNext.jsVite SPA
Router mockMust mock next/navigation and next/linkNo mock needed: use MemoryRouter from React Router
Image mockMust mock next/image to avoid jsdom issuesNo mock needed: plain <img> works in jsdom
Server ComponentsCannot render RSC in RTL tests; test only Client ComponentsNot applicable: all components are client components
API routesTest via MSW intercepting route handler fetch callsSame: test via MSW intercepting your API calls
Build outputnext build required for E2E pre-buildvite build and vite preview for E2E

Simpler vitest.setup.ts Without Next.js

tsvitest.setup.ts for a Vite SPA (no Next.js mocks needed)
// vitest.setup.ts -- Vite SPA project (much simpler than Next.js setup)
import "@testing-library/jest-dom";
import { vi } from "vitest";
import { setupServer } from "msw/node";
import { handlers } from "./src/mocks/handlers";

// MSW server for API mocking
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Mock browser APIs unavailable in jsdom
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn(),
}));

// No next/navigation mock, no next/link mock, no next/image mock needed

Testing with React Router in Vite

tsxWrapping components with MemoryRouter for tests
// test-utils/renderWithRouter.tsx -- replaces the need to mock next/navigation
import { ReactNode } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { MemoryRouter, MemoryRouterProps } from "react-router-dom";

interface Options extends Omit<RenderOptions, "wrapper"> {
  routerProps?: MemoryRouterProps;
}

export function renderWithRouter(ui: React.ReactElement, options: Options = {}) {
  const { routerProps, ...renderOptions } = options;
  return render(ui, {
    wrapper: ({ children }: { children: ReactNode }) => (
      <MemoryRouter initialEntries={["/", "/dashboard"]} {...routerProps}>
        {children}
      </MemoryRouter>
    ),
    ...renderOptions,
  });
}

// Usage:
test("navigates to dashboard link", async () => {
  const user = userEvent.setup();
  renderWithRouter(<NavBar />, { routerProps: { initialEntries: ["/"] } });

  await user.click(screen.getByRole("link", { name: "Dashboard" }));
  expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
});

Playwright Configured for Vite Preview

tsplaywright.config.ts for a Vite SPA
export default defineConfig({
  // Same tests, different webServer config
  webServer: {
    command: "yarn build && yarn preview --port 4173",
    // npm run build && npm run preview -- --port 4173
    url: "http://localhost:4173",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  use: {
    baseURL: "http://localhost:4173",
  },
});
The testing fundamentals are completely portable between Next.js and Vite. A team migrating from Vite to Next.js will primarily need to add framework mocks to their Vitest setup and update their E2E webServer config. All test logic, MSW handlers, Page Object Models, and assertions transfer without modification.