Testing

Rule

Strategy

  • MUSTWrite unit tests for core logic.
  • SHOULDWrite integration tests for features that cross boundaries.
  • MUSTWrite E2E tests with Playwright for critical user flows.
  • SHOULDCo-locate test files with source or use __tests__ directories consistently.
  • SHOULDRun pnpm test before merging.

Commands

ScopeUnitE2E
Single apppnpm testpnpm test:e2e
Monorepo (all)pnpm test
Monorepo (scoped)pnpm --filter <pkg> testpnpm --filter <app> test:e2e

Vitest

  • MUSTUse Vitest for unit and integration tests.
  • SHOULDUse Browser Mode (@vitest/browser-playwright) for component tests that need a real DOM.
  • SHOULDUse expect.element() with toBeInViewport() for visibility assertions in browser mode.

Vitest Gotchas

Code examples for these live in testing-patterns.md <vitest_gotchas> — the single source. Keep this list to the one-line rules.

  • MUSTAlways await/return promises in tests (forgetting = silent false pass).
  • MUSTUse vi.hoisted() for variables referenced inside vi.mock().
  • MUSTUse vi.mocked(fn) for typed access to mock methods instead of casting.
  • SHOULDPrefer happy-dom over jsdom for component tests (faster).
  • SHOULDUse vi.useFakeTimers() for time-dependent code; restore with vi.useRealTimers() in afterEach.
  • SHOULDUse expect.assertions(N) in async tests to catch skipped assertions.
  • SHOULDUse // @vitest-environment jsdom to override environment per file.
  • SHOULDUse --shard=1/N in CI to distribute tests across runners.

Playwright

  • MUSTUse data-testid attributes for E2E selectors.
  • MUSTUse kebab-case for test IDs, matching component filenames. See react.md.
  • NEVERSelect by text content, CSS classes, or DOM structure — these change frequently.
  • SHOULDUse semantic locators (getByRole, getByLabel) for accessible elements.
  • SHOULDPrefix child element test IDs with the parent component name.

Playwright Gotchas

Code examples live in testing-patterns.md <playwright_gotchas> — the single source. Keep this list to the one-line rules.

  • MUSTWait for hydration before interacting in Next.js apps.
  • MUSTUse --trace on (or on-first-retry) in CI for failed-test debugging.
  • SHOULDAuthenticate via API in globalSetup, not UI login (~100ms vs 2-5s per worker).
  • SHOULDStore auth with storageState and load per worker for parallel isolation.
  • SHOULDUse --shard=1/N to distribute E2E tests across CI machines.
  • SHOULDBlock unnecessary requests (analytics, images) with page.route() + route.abort().
  • SHOULDUse expect.soft() for non-blocking assertions to collect multiple failures.

E2E with External APIs

Scope note: this section covers real-API E2E for critical flows. Unit and integration tests mock these same boundaries instead — see Mocking Boundaries below.

Tests that hit real external APIs MUST run — don't skip them because "no live API". Use fail-fast patterns to control cost:

  • MUSTRun E2E tests against real APIs for critical flows. Mocks hide real failures.
  • MUSTUse aggressive timeouts (15s max for API calls, 30s max per test).
  • MUSTRun AI/LLM-dependent tests serially (test.describe.configure({ mode: "serial" })).
  • MUSTSet retries: 0 for API-dependent tests — no burning credits on flaky upstream.
  • SHOULDInclude an API health check as the first test to abort early if service is down.
  • SHOULDCentralize timeout constants (TIMEOUT.API_RESPONSE, TIMEOUT.PAGE_LOAD).

Test Quality

  • SHOULDUse MSW for API mocking in integration tests, not manual fetch stubs.

Mocking Boundaries

Scope note: this section covers unit/integration tests. Critical E2E flows deliberately hit real external APIs instead of mocking them — see E2E with External APIs above.

Mock at system boundaries. Never mock your own code.

Litmus test: Would a different implementation producing the same behavior still pass this test? If not, you're testing implementation.

Where to Mock

BoundaryMock ToolExample
External HTTP APIsMSW (http.get(...))Third-party REST/GraphQL services
DatabaseTest database or in-memory adapterPostgres, Redis, SQLite
Timevi.useFakeTimers()Debounce, expiry, scheduled jobs
File systemmemfs or temp directoriesFile uploads, log writing
RandomnessSeeded values or vi.spyOn(Math, 'random')UUIDs, tokens, shuffling
Environmentvi.stubEnv()NODE_ENV, feature flags

Where NOT to Mock

Don't MockDo This Instead
Your own modules (vi.mock('./utils'))Import and call the real code
Internal collaboratorsUse dependency injection, test through the public API
Simple data transformationsTest input → output directly
Framework internals (React, Next.js)Use testing-library, render real components

Rules

  • MUSTMock only at system boundaries — external APIs, databases, time, file system, randomness.
  • NEVERMock your own modules or internal collaborators. If you need vi.mock('./my-module'), your design needs dependency injection instead.
  • SHOULDDesign APIs as SDK-style interfaces ({ getUser, createOrder }) that accept a client parameter, not hardcoded fetch calls.
  • SHOULDAccept dependencies as parameters — functions that take a db or client argument are trivially testable with real or fake implementations.
  • SHOULDPrefer fakes (simplified real implementations) over mocks when a boundary is complex. A fake in-memory store is more trustworthy than vi.fn() with .mockResolvedValue().