Testing Strategy & Best Practices
Choose the right test types, set coverage targets that matter, build a CI pipeline, and eliminate flaky tests.
Search across all documentation pages
Choose the right test types, set coverage targets that matter, build a CI pipeline, and eliminate flaky tests.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
# .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:
| 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 |
// 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:
// 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| 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 |
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 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)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).
| 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 |
Set thresholds at 75-85% for lines, functions, and branches. Do not chase 100% -- coverage measures which lines ran, not which assertions are meaningful.
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.
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.
waitFor instead of fixed delays.beforeEach.findBy queries.skip without fixing the root cause.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.
With strict: true, TypeScript catches:
Fail fast -- run cheap checks first.
Yes. Writing a regression test for each bug ensures it never comes back. This is one of the highest-value testing practices.
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "vitest related --run"]
}
}This runs only the tests related to changed files before each commit.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥