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 Type | Speed | Confidence | Use For |
|---|---|---|---|
| TypeScript | instant | catches type errors | All code |
| Unit | fast (under 10ms each) | logic correctness | Pure functions, hooks, utilities, Zustand stores |
| Integration | medium (under 100ms each) | component behavior | Forms, lists, interactive components |
| E2E | slow (seconds each) | full system works | Login 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 implementationDealing with Flaky Tests
| Symptom | Cause | Fix |
|---|---|---|
| Test passes locally, fails in CI | Timing differences, missing env vars | Use waitFor instead of fixed delays; check env config |
| Test fails intermittently | Race conditions in async code | Add proper assertions with auto-retry |
| Test fails on first run only | State from previous test leaking | Reset state in beforeEach; use isolated browser contexts |
| Test fails with "element not found" | Element not rendered yet | Use findBy queries or waitFor |
| Screenshot test fails | Font rendering differences across OS | Run 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:runPre-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
skiphides 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
| Alternative | Use When | Don't Use When |
|---|---|---|
| Testing Trophy | You build React apps with lots of component integration | You build a utility library (then unit tests dominate) |
| Testing Pyramid | You have a traditional backend-heavy app with thin UI | Your app is mostly frontend with API calls |
| No E2E, only integration | Small project, fast iteration, no critical payment flows | You handle money, auth, or sensitive data |
| Contract testing (Pact) | Microservices with many teams and API consumers | Monolith 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
waitForinstead of fixed delays. - State leakage: reset state in
beforeEach. - Element not found: use
findByqueries. - Never mark flaky tests as
skipwithout 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?
- Lint + typecheck (fast, every push)
- Unit/integration tests (every push)
- E2E tests (PRs and merges)
- 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.
Related
- Vitest Setup -- setting up the unit test runner
- React Testing Library Fundamentals -- integration testing patterns
- Playwright E2E Setup -- E2E test runner
- Testing Server Components & Actions -- RSC testing strategies