跳转到主要内容

Testing

Testing increases confidence in change: cover critical paths, avoid brittle tests, and make failures actionable.

A good test suite is like a safety net: it catches regressions quickly without slowing down development. That requires discipline in what you test and how you structure boundaries.

What to test first

Prioritize by impact and frequency. The most valuable tests protect the paths users hit every day.

  • Critical user journeys (login, checkout, save flows).
  • Error/loading/empty states—these break often and are user-visible.
  • Logic boundaries (`reducers`, parsers, validators).

The testing pyramid (pragmatic)

Think in terms of feedback speed and confidence. Most coverage should be fast and stable; reserve slow tests for critical paths.

If your suite is slow, people stop running it. If it is flaky, people stop trusting it. Speed and determinism are first-class requirements.

  • Unit tests: pure logic (reducers, parsers, validators). Fastest and most stable.
  • Integration tests: components + data boundaries (render, interactions, network mocks).
  • `E2E` tests: only the flows you cannot afford to break. Slowest and most expensive.

Test behavior, not implementation

  • Prefer assertions on what the user sees/does (text, enabled/disabled, navigation), not internal state shape.
  • When refactoring, behavior should stay stable; tests should not block refactors.

Replaceable boundaries: time, network, randomness

Flaky tests often come from uncontrolled time and network. Make boundaries injectable so tests can be deterministic.

This is also a design benefit: code that is easy to test is usually easier to maintain, because dependencies are explicit.

TypeScript
export type Clock = { now(): number };
export type Random = { next(): number };
export type Fetcher = (url: string) => Promise<{ ok: boolean; json(): Promise<any> }>;

export function createServices(deps: Partial<{ clock: Clock; random: Random; fetcher: Fetcher }> = {}) {
  const clock: Clock = deps.clock ?? { now: () => Date.now() };
  const random: Random = deps.random ?? { next: () => Math.random() };
  const fetcher: Fetcher =
    deps.fetcher ?? (async (url) => fetch(url));

  return { clock, random, fetcher };
}

// Production
const services = createServices();

// Test (deterministic)
const testServices = createServices({
  clock: { now: () => 1700000000000 },
  random: { next: () => 0.42 },
  fetcher: async () => ({ ok: true, json: async () => ({ items: [] }) }),
});

Common pitfalls

  • Over-mocking so tests drift away from real behavior.
  • Only testing the happy path and ignoring `loading`/`error`/`empty` states.
  • Using real timers/network in `CI` causing flakiness and slow feedback.

Production checklist

  • Critical flows have at least one stable E2E test; the rest is covered by faster tests.
  • `loading`/`error`/`empty` states are tested (they are user-visible and easy to regress).
  • `CI` signals are actionable: `lint`, `typecheck`, `build`, and a small smoke suite.
  • Tests are deterministic: time/network/randomness are controlled.

Further reading

Testing - Guides - React Docs - React 文档