React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

strategybest-practicescoverageciflaky-teststesting-pyramidtesting-trophy

Testing Strategy & Best Practices

Choose the right test types, set coverage targets that matter, build a CI pipeline, and eliminate flaky tests.

Recipe

Quick-reference recipe card -- copy-paste ready.

Testing Trophy (Kent C. Dodds model for React apps):
                    ┌──────────┐
                    │   E2E    │  Few critical paths
                  ┌─┴──────────┴─┐
                  │ Integration   │  Most tests live here
                ┌─┴──────────────┴─┐
                │   Unit Tests     │  Utility functions, hooks
              ┌─┴──────────────────┴─┐
              │   Static Analysis    │  TypeScript, ESLint
              └──────────────────────┘
# Test strategy decision matrix
What to test:
  - User-visible behavior (renders, interactions, navigation)
  - Business logic (calculations, validation, state transitions)
  - Edge cases (empty states, errors, loading, boundary values)
  - Regression bugs (write a test for every bug fix)
 
What NOT to test:
  - Implementation details (internal state, private methods)
  - Third-party library internals
  - CSS styling (unless visual regression matters)
  - Trivial code (simple prop passthrough components)

When to reach for this: Before writing any tests -- plan your testing strategy based on project size, team, and risk tolerance.

Working Example

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  # Stage 1: Fast checks (run on every push)
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
 
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
 
  # Stage 2: Unit and integration tests (run on every push)
  unit-tests:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
 
      - run: npm ci
      - run: npm run test:run -- --coverage
 
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
 
  # Stage 3: E2E tests (run on PRs to main and merges)
  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
 
      - run: npm ci
      - run: npx playwright install --with-deps chromium
 
      - name: Build application
        run: npm run build
 
      - name: Run E2E tests
        run: npx playwright test --project=chromium
        env:
          CI: true
 
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
 
  # Stage 4: Deploy (only on main)
  deploy:
    runs-on: ubuntu-latest
    needs: [unit-tests, e2e-tests]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploy step here"
// package.json scripts
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

What this demonstrates:

  • 4-stage CI pipeline: lint/typecheck (fast), unit tests, E2E tests, deploy
  • E2E tests run against a production build
  • Artifacts uploaded for debugging failures
  • Coverage report generation
  • E2E only runs in CI with a single browser for speed

Deep Dive

How It Works

  • Static analysis (TypeScript + ESLint) catches type errors and code quality issues instantly -- no runtime cost
  • Unit tests run in milliseconds per test -- test pure functions, custom hooks, and utility modules
  • Integration tests (Testing Library) render real components with real child components -- test user-facing behavior
  • E2E tests (Playwright) run in real browsers against a running app -- test full user journeys across pages
  • The CI pipeline runs fast checks first and gates slow checks behind them -- fail fast, save compute

When to Use Each Test Type

Test TypeSpeedConfidenceUse For
TypeScriptinstantcatches type errorsAll code
Unitfast (under 10ms each)logic correctnessPure functions, hooks, utilities, Zustand stores
Integrationmedium (under 100ms each)component behaviorForms, lists, interactive components
E2Eslow (seconds each)full system worksLogin flows, checkout, critical paths

Coverage Guidelines

// vitest.config.ts -- practical coverage thresholds
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      thresholds: {
        // Set meaningful thresholds, not 100%
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.stories.{ts,tsx}",
        "src/**/*.d.ts",
        "src/app/**/layout.tsx",
        "src/app/**/loading.tsx",
        "src/app/**/not-found.tsx",
      ],
    },
  },
});

When coverage lies:

  • 100% line coverage does not mean 100% of behaviors are tested
  • Coverage measures which lines ran, not which assertions passed
  • A test that renders a component and asserts nothing still increases coverage
  • Focus on testing critical paths and edge cases, not hitting a number

Test Naming Conventions

// Pattern: describe(unit) > it(behavior)
describe("CartStore", () => {
  it("adds item to empty cart");
  it("increments quantity for duplicate items");
  it("removes item by id");
  it("calculates total with mixed quantities");
});
 
// Pattern: should/when for complex behaviors
describe("CheckoutForm", () => {
  it("submits order when all fields are valid");
  it("shows validation error when email is missing");
  it("disables submit button while processing");
});
 
// Avoid:
it("works"); // too vague
it("test addItem"); // do not start with "test"
it("should call setCount with count + 1"); // tests implementation

Dealing with Flaky Tests

SymptomCauseFix
Test passes locally, fails in CITiming differences, missing env varsUse waitFor instead of fixed delays; check env config
Test fails intermittentlyRace conditions in async codeAdd proper assertions with auto-retry
Test fails on first run onlyState from previous test leakingReset state in beforeEach; use isolated browser contexts
Test fails with "element not found"Element not rendered yetUse findBy queries or waitFor
Screenshot test failsFont rendering differences across OSRun visual tests in Docker or limit to one platform

Variations

Minimal CI for small projects:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run test:run

Pre-commit hooks for fast feedback:

// package.json (with lint-staged)
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "vitest related --run"]
  }
}

TypeScript Notes

// TypeScript IS a testing tool -- strict mode catches bugs before tests
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
 
// These TypeScript errors replace entire categories of tests:
// - null/undefined access tests (strictNullChecks)
// - wrong argument type tests (strict function types)
// - missing property tests (exact optional property types)

Gotchas

  • Testing implementation details -- Asserting on internal state, CSS classes, or mock call counts couples tests to code structure. Fix: Test what the user sees and does. If the user cannot observe it, the test probably should not assert it.

  • Too many E2E tests -- E2E tests are slow, expensive to maintain, and prone to flakiness. Fix: Cover critical paths (login, checkout, signup) with E2E. Use integration tests for everything else.

  • Chasing 100% coverage -- Teams spend disproportionate effort testing trivial code to hit arbitrary targets. Fix: Set coverage thresholds at 75-85%. Focus manual effort on testing business-critical code.

  • Not running tests in CI -- Tests that only run locally eventually break without anyone noticing. Fix: Set up CI from day one. Block merges on test failures.

  • Ignoring flaky tests -- Marking flaky tests as skip hides real bugs. Fix: Fix the root cause (usually timing or state leakage). Quarantine flaky tests in a separate job if needed.

  • Testing third-party libraries -- Writing tests that verify your UI library renders a dropdown correctly wastes time. Fix: Trust that libraries are tested. Test your usage of them (do you pass correct props, handle callbacks).

Alternatives

AlternativeUse WhenDon't Use When
Testing TrophyYou build React apps with lots of component integrationYou build a utility library (then unit tests dominate)
Testing PyramidYou have a traditional backend-heavy app with thin UIYour app is mostly frontend with API calls
No E2E, only integrationSmall project, fast iteration, no critical payment flowsYou handle money, auth, or sensitive data
Contract testing (Pact)Microservices with many teams and API consumersMonolith or single team

FAQs

What is the Testing Trophy and how does it differ from the Testing Pyramid?
  • The Testing Trophy (Kent C. Dodds) emphasizes integration tests for React apps, with fewer unit and E2E tests.
  • The Testing Pyramid emphasizes many unit tests at the base.
  • Use the Trophy for frontend-heavy apps; use the Pyramid for backend-heavy apps.
What should I NOT test?
  • Implementation details (internal state, private methods)
  • Third-party library internals
  • CSS styling (unless visual regression matters)
  • Trivial code (simple prop passthrough components)
What is a practical coverage threshold?

Set thresholds at 75-85% for lines, functions, and branches. Do not chase 100% -- coverage measures which lines ran, not which assertions are meaningful.

Gotcha: Does 100% code coverage mean my app is fully tested?

No. A test that renders a component and asserts nothing still increases coverage. Coverage measures line execution, not behavior verification. Focus on testing critical paths and edge cases.

How should I name my tests?

Use describe(unit) > it(behavior):

describe("CartStore", () => {
  it("adds item to empty cart");
  it("increments quantity for duplicate items");
});

Avoid vague names like "works" or implementation-detail names.

What causes flaky tests and how do I fix them?
  • Timing issues: use waitFor instead of fixed delays.
  • State leakage: reset state in beforeEach.
  • Element not found: use findBy queries.
  • Never mark flaky tests as skip without fixing the root cause.
When should I use E2E tests vs integration tests?
  • E2E: critical user paths (login, checkout, signup) that need real browser behavior.
  • Integration: component behavior, form interactions, state management.
  • E2E tests are slow and expensive -- keep them focused on high-value flows.
Gotcha: Why is testing implementation details harmful?

Asserting on internal state, CSS classes, or mock call counts couples tests to code structure. When you refactor, tests break even though behavior is unchanged. Test what the user sees instead.

How does TypeScript reduce the need for certain tests?

With strict: true, TypeScript catches:

  • null/undefined access (replaces null-check tests)
  • Wrong argument types (replaces type-check tests)
  • Missing properties (replaces property existence tests)
What should my CI pipeline look like?
  1. Lint + typecheck (fast, every push)
  2. Unit/integration tests (every push)
  3. E2E tests (PRs and merges)
  4. Deploy (main branch only)

Fail fast -- run cheap checks first.

Should I write a test for every bug fix?

Yes. Writing a regression test for each bug ensures it never comes back. This is one of the highest-value testing practices.

How do I use pre-commit hooks for fast test feedback?
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "vitest related --run"]
  }
}

This runs only the tests related to changed files before each commit.