Testing Strategy
Build confidence through a layered testing strategy: unit tests for logic, integration tests for component contracts, and end-to-end tests for critical user paths.
Testing is not about achieving 100% coverage — it is about testing the right things at the right layer. Each layer of the pyramid has a distinct purpose, cost, and feedback speed. Understand those trade-offs and invest accordingly.
The Testing Pyramid
The testing pyramid describes the ideal ratio of test types. Fast, cheap unit tests form the broad base. Slower, costlier end-to-end tests form the narrow tip. Inverting this pyramid — writing mostly E2E tests — results in slow feedback cycles and brittle test suites.
| Layer | What it tests | Speed | Cost | Tool |
|---|---|---|---|---|
| Unit | Pure functions, hooks, business logic in isolation | < 1ms per test | Very low | Vitest / Jest |
| Integration | Component rendering, user interactions, API mocking | 10–100ms per test | Medium | Testing Library + Vitest |
| End-to-End | Full user flows in a real browser | 1–10s per test | High | Playwright |
| Visual Regression | UI appearance across breakpoints and states | Medium | Medium | Chromatic / Percy |
| Accessibility | WCAG violations in rendered components | Fast | Low | axe-core + Testing Library |
If a test takes more than a few hundred milliseconds to run, ask whether it belongs one layer higher. A slow unit test is often an integration test in disguise.
Unit Tests
Unit tests verify discrete, pure logic. Write them for custom hooks, utility functions, data transformation functions, and validation schemas. Do not write unit tests for React component rendering — that is the job of integration tests.
// hooks/use-cart.test.ts
import { renderHook, act } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { useCart } from "./use-cart"
describe("useCart", () => {
it("adds an item to the cart", () => {
const { result } = renderHook(() => useCart())
act(() => {
result.current.addItem({ id: "1", name: "Widget", price: 9.99, quantity: 1 })
})
expect(result.current.items).toHaveLength(1)
expect(result.current.total).toBe(9.99)
})
it("increments quantity when adding an existing item", () => {
const { result } = renderHook(() => useCart())
act(() => {
result.current.addItem({ id: "1", name: "Widget", price: 9.99, quantity: 1 })
result.current.addItem({ id: "1", name: "Widget", price: 9.99, quantity: 1 })
})
expect(result.current.items[0].quantity).toBe(2)
expect(result.current.total).toBe(19.98)
})
})Testing utility functions
// lib/format-currency.test.ts
import { describe, it, expect } from "vitest"
import { formatCurrency } from "./format-currency"
describe("formatCurrency", () => {
it("formats USD correctly", () => {
expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50")
})
it("handles zero", () => {
expect(formatCurrency(0, "USD")).toBe("$0.00")
})
it("handles negative values", () => {
expect(formatCurrency(-50, "USD")).toBe("-$50.00")
})
})Integration Tests
Integration tests render a component in a simulated browser environment and assert on what the user sees and can do. Use React Testing Library — it encourages testing behaviour over implementation details, making tests resilient to refactors.
The core principle of React Testing Library: query elements the way a user would find them. Prefer getByRole and getByLabelText over getByTestId. Test IDs are a last resort.
// components/login-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { vi, describe, it, expect } from "vitest"
import { LoginForm } from "./login-form"
describe("LoginForm", () => {
it("submits credentials and calls onSuccess", async () => {
const user = userEvent.setup()
const onSuccess = vi.fn()
render(<LoginForm onSuccess={onSuccess} />)
await user.type(screen.getByLabelText(/email/i), "user@example.com")
await user.type(screen.getByLabelText(/password/i), "password123")
await user.click(screen.getByRole("button", { name: /sign in/i }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
})
it("shows validation error for empty fields", async () => {
const user = userEvent.setup()
render(<LoginForm onSuccess={vi.fn()} />)
await user.click(screen.getByRole("button", { name: /sign in/i }))
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
})
})Mocking API requests with MSW
Use Mock Service Worker (MSW) to intercept network requests in tests. This tests your actual fetch logic, not a mocked function — giving you much higher confidence that the integration between your component and your API works correctly.
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw"
export const handlers = [
http.get("/api/user/:id", ({ params }) => {
return HttpResponse.json({
id: params.id,
name: "Jane Smith",
email: "jane@example.com",
})
}),
http.post("/api/login", async ({ request }) => {
const { email } = await request.json() as { email: string }
if (email === "invalid@example.com") {
return HttpResponse.json({ error: "Invalid credentials" }, { status: 401 })
}
return HttpResponse.json({ token: "mock-jwt-token" })
}),
]End-to-End Tests
E2E tests run a real browser against your running application. Reserve them for your most critical user paths — the flows that, if broken, would immediately harm the business. Authentication, checkout, and core CRUD operations are good candidates.
// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test"
test.describe("Checkout flow", () => {
test.beforeEach(async ({ page }) => {
// Log in as test user before each test
await page.goto("/login")
await page.getByLabel("Email").fill("test@example.com")
await page.getByLabel("Password").fill("testpassword")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page).toHaveURL("/dashboard")
})
test("completes a purchase", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Add to cart" }).first().click()
await page.getByRole("link", { name: "Cart (1)" }).click()
await page.getByRole("button", { name: "Proceed to checkout" }).click()
// Fill shipping info
await page.getByLabel("Street address").fill("123 Main St")
await page.getByLabel("City").fill("New York")
await page.getByRole("button", { name: "Continue to payment" }).click()
// Confirm order
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible()
})
})E2E tests that test everything end up testing nothing reliably. If you have more than 20–30 E2E tests, re-evaluate whether some could be replaced by integration tests. E2E suites that take over 10 minutes to run are routinely skipped by developers.
Visual Regression Testing
Visual regression tests capture screenshots of components and compare them against approved baselines. When a PR changes a button colour by 2% or shifts layout by 3px, the diff is surfaced before review — not discovered after deployment.
For design engineers who own the design system, visual regression is the highest-ROI test category. It catches the bugs that code review misses: subtle colour drift, spacing regressions, font rendering changes, and responsive breakage.
What to test visually
| Surface | Why |
|---|---|
| Every design system component | These are used everywhere — a 1px regression on Button affects every page |
| Every component variant and state | Button/primary/hover can break independently of Button/primary/default |
| Every breakpoint | A component that looks correct at 1440px may overflow at 375px |
| Dark mode and light mode | Theme regressions are invisible in code review |
| Key page layouts | Dashboard, auth, and landing layouts — the shells that every page depends on |
Workflow
1. Developer opens PR with component changes
2. CI builds Storybook and captures screenshots
3. Chromatic / Percy compares against approved baselines
4. Diffs appear in the PR — reviewer accepts or requests changes
5. Accepted screenshots become the new baselineChromatic example
Chromatic integrates with Storybook to capture and diff component screenshots automatically. It is the most widely adopted tool for visual regression in component libraries.
# .github/workflows/chromatic.yml
name: Chromatic
on:
pull_request:
paths:
- 'components/ui/**'
- 'app/globals.css'
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build-storybook
- uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_TOKEN }}
storybookBuildDir: storybook-staticAccessibility automation in visual tests
Tools like axe-core can be integrated into visual testing workflows to catch accessibility violations alongside visual diffs:
// .storybook/test-runner.ts
import { getStoryContext } from '@storybook/test-runner'
import { injectAxe, checkA11y } from 'axe-playwright'
export default {
async postRender(page, context) {
const storyContext = await getStoryContext(page, context)
if (storyContext.parameters?.a11y?.disable) return
await injectAxe(page)
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: { html: true },
})
},
}Visual regression tests and accessibility automation run in CI on every PR that touches components. They add minutes to the build but prevent hours of post-deployment debugging. For design system work, they are not optional — they are the safety net that lets you refactor tokens and components with confidence. A CI pipeline without visual regression is shipping blind.
Coverage Targets
Coverage is a signal, not a goal. A component with 95% coverage can still have critical bugs if the wrong 5% is untested. Focus coverage requirements on high-risk areas: business logic, form validation, authentication, and payment flows.
| Area | Recommended coverage | Rationale |
|---|---|---|
| Utility functions | 95–100% | Pure functions with clear inputs/outputs — exhaustive testing is cheap |
| Custom hooks | 90–100% | Encapsulate business logic — high value per test written |
| Form components | 80–90% | Validation paths, error states, and submission need explicit coverage |
| Page components | 60–80% | Cover primary and error paths; avoid testing third-party rendering |
| UI-only components | 50–70% | Snapshot or visual regression tests often more appropriate |
| Third-party integrations | Mock boundary | Test your integration code, not the library itself |
Writing tests is the highest-ROI task to delegate to AI agents. Tests follow repetitive patterns that agents handle well. Provide the component spec and test cases as bullet points — agents generate the full test file with correct imports and assertions. Prompt with: "Generate tests for all states including loading, empty, error, and edge cases." The act of pre-defining test cases also forces you to consider edge cases before implementation.
CI Integration
Tests only provide value if they run automatically. Every pull request should trigger the full test suite. Failing tests must block merges — a test suite that can be bypassed is not a safety net.
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
jobs:
unit-and-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm test --coverage
- uses: codecov/codecov-action@v4
e2e:
runs-on: ubuntu-latest
needs: unit-and-integration
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/-
Run unit and integration tests first Fast tests run on every push. They catch the majority of bugs in under 60 seconds. Gate E2E tests behind this step — no point running a 10-minute browser suite if the unit tests are already failing.
-
Run E2E tests on pull requests only E2E tests are expensive. Run them on PRs targeting main, not on every push to a feature branch. This keeps developer feedback loops fast while still protecting the main branch.
-
Upload failure artifacts Playwright generates HTML reports and screenshots on failure. Upload them as CI artifacts so developers can inspect what went wrong without re-running the full suite locally.
-
Block merges on test failure Configure branch protection rules in GitHub to require the test pipeline to pass before a PR can be merged. This is non-negotiable — tests that do not block merges are advisory only.