"The best ROI for UI is integration tests — they test what the user experiences, not how you built it. I use unit tests for pure logic, integration for component behaviour, e2e sparingly for critical journeys. And I measure escaped defects, not coverage %."
Unit tests of React components test implementation details → break on refactor without catching bugs. Integration tests test behaviour the user sees.
| # | Query | Why |
|---|---|---|
| 1 | getByRole | ARIA semantics — tests a11y too |
| 2 | getByLabelText | Form inputs with labels |
| 3 | getByText | Visible text content |
| 4 | getByTestId | Last resort — not user-visible |
getBy* — sync, throws immediately if absentfindBy* — always use after async ops (fetches, state updates)queryBy* — sync, returns null (use to assert absence)userEvent over fireEvent — fires the full browser event sequence (pointerdown→mousedown→focus→click), catches more real bugs.
| Need | Tool |
|---|---|
| Runner | Vitest (ESM-native, fast) / Jest |
| UI interaction | React Testing Library |
| Network mock | MSW — network-level, lib-agnostic |
| e2e | Playwright (multi-browser, sharded) |
| Visual regression | Chromatic (Storybook cloud) / Percy / Playwright toHaveScreenshot() |
MSW > axios mock: intercepts at network level, works with any fetch lib, same handlers in tests AND browser.
Screenshot diff on every PR — catches unintended CSS changes (spacing, colour, layout). Storybook play() = document + interact + visual regression from one story.
Does NOT catch functional bugs (behaviour, wrong data)
Catches unintended CSS regressions: spacing, font weight, colour, layout shift
Trap: "accept all" habit. Scope to shared primitives only. Make the designer (not engineer) the approver. Keep it informational — not a hard gate.
| Cause | Fix |
|---|---|
| Timing | await findBy* not getBy* for async |
| Shared state | Cleanup in afterEach; RTL auto-cleanup |
| Date/time | vi.useFakeTimers() + reset in afterEach |
| Real network | MSW in unit/integration; route.fulfill() in e2e |
| Animations | Disable in test env via prefers-reduced-motion |
| Fragile selectors | ARIA roles; data-testid as last resort |
Fleet mgmt: quarantine (skip + ticket) → sprint dedicate 20% to fixes → track flakiness rate (target <2%).
test-utils pkg (RTL + MSW pre-configured)Testing trophy → RTL+MSW integration tests for most UI → Playwright sharded for critical journeys → quarantine-not-retry for flakiness → shared test-utils package → escaped defects as the quality metric. "I make tests easy to write, then make them the default."
Retries ≠ fix: CI retries hide the root cause. Quarantine flaky tests (skip + ticket), diagnose (timing? shared state? real network?), then fix. A retry count >1 is a signal to investigate, not a success metric.
100% coverage ≠ quality: render(<Component />) without assertions gives line coverage with zero confidence. Track escaped defects instead. Set a low floor gate; don't chase a ceiling.