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 testbefore merging.
Commands
| Scope | Unit | E2E |
|---|---|---|
| Single app | pnpm test | pnpm test:e2e |
| Monorepo (all) | pnpm test | — |
| Monorepo (scoped) | pnpm --filter <pkg> test | pnpm --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()withtoBeInViewport()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/returnpromises in tests (forgetting = silent false pass). - MUSTUse
vi.hoisted()for variables referenced insidevi.mock(). - MUSTUse
vi.mocked(fn)for typed access to mock methods instead of casting. - SHOULDPrefer
happy-domoverjsdomfor component tests (faster). - SHOULDUse
vi.useFakeTimers()for time-dependent code; restore withvi.useRealTimers()inafterEach. - SHOULDUse
expect.assertions(N)in async tests to catch skipped assertions. - SHOULDUse
// @vitest-environment jsdomto override environment per file. - SHOULDUse
--shard=1/Nin CI to distribute tests across runners.
Playwright
- MUSTUse
data-testidattributes 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(oron-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
storageStateand load per worker for parallel isolation. - SHOULDUse
--shard=1/Nto 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: 0for 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
| Boundary | Mock Tool | Example |
|---|---|---|
| External HTTP APIs | MSW (http.get(...)) | Third-party REST/GraphQL services |
| Database | Test database or in-memory adapter | Postgres, Redis, SQLite |
| Time | vi.useFakeTimers() | Debounce, expiry, scheduled jobs |
| File system | memfs or temp directories | File uploads, log writing |
| Randomness | Seeded values or vi.spyOn(Math, 'random') | UUIDs, tokens, shuffling |
| Environment | vi.stubEnv() | NODE_ENV, feature flags |
Where NOT to Mock
| Don't Mock | Do This Instead |
|---|---|
Your own modules (vi.mock('./utils')) | Import and call the real code |
| Internal collaborators | Use dependency injection, test through the public API |
| Simple data transformations | Test 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 hardcodedfetchcalls. - SHOULDAccept dependencies as parameters — functions that take a
dborclientargument 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().