Design Engineering
Quality & Polish

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.

LayerWhat it testsSpeedCostTool
UnitPure functions, hooks, business logic in isolation< 1ms per testVery lowVitest / Jest
IntegrationComponent rendering, user interactions, API mocking10–100ms per testMediumTesting Library + Vitest
End-to-EndFull user flows in a real browser1–10s per testHighPlaywright
Visual RegressionUI appearance across breakpoints and statesMediumMediumChromatic / Percy
AccessibilityWCAG violations in rendered componentsFastLowaxe-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.

Testing a custom hook with Vitest
// 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

Pure function test — no React involved
// 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.

Component integration test with user interactions
// 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.

MSW handler setup
// 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.

Playwright E2E — checkout flow
// 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

SurfaceWhy
Every design system componentThese are used everywhere — a 1px regression on Button affects every page
Every component variant and stateButton/primary/hover can break independently of Button/primary/default
Every breakpointA component that looks correct at 1440px may overflow at 375px
Dark mode and light modeTheme regressions are invisible in code review
Key page layoutsDashboard, 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 baseline

Chromatic 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 Actions — Chromatic workflow
# .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-static

Accessibility automation in visual tests

Tools like axe-core can be integrated into visual testing workflows to catch accessibility violations alongside visual diffs:

Storybook + axe-core accessibility check
// .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.

AreaRecommended coverageRationale
Utility functions95–100%Pure functions with clear inputs/outputs — exhaustive testing is cheap
Custom hooks90–100%Encapsulate business logic — high value per test written
Form components80–90%Validation paths, error states, and submission need explicit coverage
Page components60–80%Cover primary and error paths; avoid testing third-party rendering
UI-only components50–70%Snapshot or visual regression tests often more appropriate
Third-party integrationsMock boundaryTest 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 Actions — test pipeline
# .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/
  1. 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.

  2. 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.

  3. 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.

  4. 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.

On this page