Testing Checklist
A pragmatic checklist for writing and reviewing tests. Read the relevant section for the change at hand rather than the whole file. Tests are the executable spec — optimize them for reading, not for coverage numbers.
Rationalizations (read first)
| Rationalization | Rebuttal |
|---|---|
| "I'll add tests later." | Later is the load-bearing word — it rarely arrives. The test is part of the change, not a follow-up. |
| "Tests pass, ship it." | Passing tests are evidence, not proof. They only cover what they assert — check what they don't cover. |
| "This code is too simple to break." | Simple code with branch logic breaks at the boundaries. Test the boundaries, skip the trivial middle. |
| "Coverage is at N%, we're good." | Coverage measures execution, not assertion. A test with no meaningful assert inflates N and catches nothing. |
| "The bug is fixed, no test needed." | A fix without a regression test is a bug on a return ticket. Red first, then green. |
| "I'll write the code first, then add tests." | Test-after validates the code you happened to write, not the behavior you wanted. Red first keeps the test honest. |
| "I'll DRY up these tests with helpers." | Test code is spec, not production code. DAMP wins: some duplication beats indirection the reader must unfold. |
What to test (in priority order)
- Branch logic — every non-trivial
if/switch/early-return path, especially error paths. - Boundaries — empty input, one item, max size, null/undefined, zero, negative, off-by-one edges.
- Contracts — what callers rely on: return shapes, thrown error types, emitted events, side effects.
- Error handling — the failure path is production code too; assert what happens when the dependency fails.
- Regressions — every fixed bug gets a test that fails without the fix (write it first, watch it fail).
What NOT to test
- Implementation details — private helpers, internal state, call order that a refactor may change. Test through the public surface.
- The framework — React rendering, ORM query building, library internals. Assume they work; test your logic.
- Trivial pass-throughs — getters, re-exports, config objects with no logic.
- Exact copies of the implementation — a test that mirrors the code's algorithm proves nothing; assert on known input → expected output instead.
Shape of the suite
- Pyramid roughly holds: mostly fast unit tests, some integration at real seams (API route + DB, component + store), few end-to-end. Inverted pyramids (E2E-heavy) are slow and flaky — flag them.
- Integration tests sit at real seams — if everything meaningful is mocked, it's a unit test with extra steps and false confidence.
- Each test asserts one behavior; the name states it (
rejects expired token, nottest auth 2). - Deterministic: no real time (
vi.useFakeTimers), no real network, no shared mutable state between tests, no order dependence. - No logic in tests — a
for/ifinside a test is a test that can be wrong. Table-driven cases (it.each) are fine; computation of the expected value is not.
Test quality (DAMP over DRY)
- The failing output alone tells you what broke — descriptive name, explicit expected value, no dig into helpers to understand the setup.
- Setup visible at the test site: prefer inline literals or a plainly-named builder over layers of
beforeEachmutation. - Assert on values, not on "did not throw".
- Mocks are visible in the test that uses them; a mock configured three files away is a trap for the next reader.
Stack specifics (Vitest / React Testing Library)
- Query by role/label/text (
getByRole('button', { name: /save/i })) — testid only when no accessible query exists (that gap is often an a11y bug worth fixing instead). -
userEventoverfireEvent— it simulates real interaction sequences (focus, keydown, click). - Async UI:
await findBy…/waitForwith a real assertion inside — never sleep-and-hope. - Assert what the user sees/experiences, not component internals (
state, props of children, hook return values). - MSW (or fetch-mock at the boundary) for network — don't mock your own data-fetching wrapper; mock the wire.
TDD loop (new behavior)
For new code, not just bug fixes — let the test drive the design, red before green.
- Agree the seam first. Decide where the test attaches — a public function, an API route, a component's rendered output — and confirm it before writing the test. Testing below an agreed seam couples the suite to internals; testing above it asserts nothing of yours.
- Red. Write one failing test for the next thin slice of behavior. Run it, watch it fail for the intended reason.
- Green. Write the least code that makes it pass. No more.
- Refactor — outside the loop. Clean up with the test green as the safety net. Refactoring is its own step, never folded into red or green.
Slice vertically — a tracer bullet, one thin path through every layer, demoable — not horizontally (a whole layer at once, testable only much later). One slice at a time.
Bug-fix protocol
- Write the test that reproduces the bug. Run it — it must fail for the reported reason.
- Fix. Run it — green.
- Run the surrounding suite — the fix didn't move the bug next door.
If step 1 is impossible (non-deterministic, environment-bound), say so explicitly per the Verification Exit Criterion — never imply tested-and-covered when it isn't.
Delegation
Delegate the scaffolding, keep the coverage judgment. Hand mechanical generation to a cheaper-tier subagent — test boilerplate, fixtures, obvious table-driven cases, a codemod across many test files — returning ready-to-review tests. Keep on the main model the parts that need judgment: choosing the seam, deciding what is worth testing, and confirming the behavior is actually covered. A green suite is evidence, not proof the acceptance criteria are met.