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.
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.
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.
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
| Feature | Vitest | Jest |
|---|---|---|
| ESM support | Native, no transform | Requires @jest/globals or transform config |
| TypeScript | Native via Vite pipeline | Requires ts-jest or babel-jest |
| Speed | Faster (Vite HMR, worker threads) | Slower cold start, no HMR in watch mode |
| Config reuse | Shares vite.config.ts | Separate jest.config.ts required |
| API compatibility | Mostly Jest-compatible | The reference API |
| Next.js 15 support | Needs jsdom + mocks for next/* | Same: needs jest-environment-jsdom + mocks |
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, "./"),
},
},
});// 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(/-/);
});
});// 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);
});
});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.
Query Priority
RTL provides multiple ways to find elements. The recommended order of preference mirrors what assistive technology uses:
getByRole(highest priority): semantic roles likebutton,textbox,combobox,heading. This also verifies accessibility.getByLabelText: form inputs associated with a label. Enforces proper label associations.getByPlaceholderText: only when label is not available (fallback).getByText: for non-interactive elements with visible text content.getByTestId(lowest priority): use only when semantic queries are not possible. Never as the default.
// 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();
});
});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
// 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.
// 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)
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:
// 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
// __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.
vitest --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.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
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:3000import { 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.
// 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
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
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
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-motionrequires visual inspection.
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
setTimeoutor arbitrarysleepinstead of properwaitFor - 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
// 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();
});// 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.
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.
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:
#!/bin/sh
# Type check
yarn tsc --noEmit || exit 1
# Unit and integration tests only (E2E runs in CI)
yarn test --run || exit 1Coverage 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.
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
| Layer | Recommended Coverage | Rationale |
|---|---|---|
| Utility functions | 90-100% | Pure functions, easy to test exhaustively |
| Business logic hooks | 85-95% | Complex state, important branches need verification |
| UI components | 70-85% | Integration tests cover most paths; not every visual branch needs a test |
| Auth and security | 95-100% | Every auth branch has security implications |
| Page components | 60-75% | E2E covers critical paths; not worth unit-testing every layout detail |
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 Type | Write Time | Run Time | Maintenance | Confidence | Best For |
|---|---|---|---|---|---|
| TypeScript | Zero | ~3s | Very low | High (type errors) | Contracts, API shapes, prop types |
| Unit (Vitest) | Low | < 1ms/test | Medium (refactor breaks) | Medium (isolated) | Pure functions, hooks, complex logic |
| Integration (RTL) | Medium | 10-100ms/test | Low (behaviour stable) | High (user perspective) | Component flows, form submission, UI state |
| E2E (Playwright) | High | 5-60s/test | High (UI changes break) | Very high (real browser) | Critical paths: login, checkout, core feature |
| Visual regression | Medium | Slow (screenshots) | High (intentional changes) | High (pixel diff) | Design system components, stable UI surfaces |
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
// 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=uiShared Test Utilities and Factories
// 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
// 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
Testing Anti-Patterns to Avoid
1. Testing Implementation Details
// 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
// 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 mocks5. 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.
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
| Concern | Next.js | Vite SPA |
|---|---|---|
| Router mock | Must mock next/navigation and next/link | No mock needed: use MemoryRouter from React Router |
| Image mock | Must mock next/image to avoid jsdom issues | No mock needed: plain <img> works in jsdom |
| Server Components | Cannot render RSC in RTL tests; test only Client Components | Not applicable: all components are client components |
| API routes | Test via MSW intercepting route handler fetch calls | Same: test via MSW intercepting your API calls |
| Build output | next build required for E2E pre-build | vite build and vite preview for E2E |
Simpler vitest.setup.ts Without Next.js
// 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 neededTesting with React Router in Vite
// 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
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",
},
});webServer config. All test logic, MSW handlers, Page Object Models, and assertions transfer without modification.