Guide

Tests exist to give you confidence to change code. A suite that shatters on every rename teaches the team to fear cleanup. Structure tests around stable contracts: public APIs, HTTP routes, message schemas, pure domain functions, and the side effects you actually care about.

Coverage percentage is a weak goal. 100% coverage of shallow getters with zero boundary tests is theater. Prefer covering risky behavior: money, auth, concurrency, migrations, and anything that has burned you before.

Concept

Prefer fakes and real dependencies over spy webs on private helpers. See test levels for where each assertion belongs.

Walking through a stronger assertion

TypeScript
// Weak: locked to implementation
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ status: "paid" }))

// Stronger: asserts observable outcome
const order = await checkout(cartId, { paymentMethod: "card_visa" })
expect(order.status).toBe("paid")
expect(await emails.findTo(user.email)).toContainEqual(
  expect.objectContaining({ template: "order_receipt" }),
)

Prioritize:

  • Invariants (balances never negative, idempotent webhooks)
  • Parsing and validation at system edges
  • State machines and branching business rules
  • Failure modes you have seen in production

Skip or minimize:

  • Trivial getters and setters
  • Third-party library behavior (trust Lodash; test your use of it at the boundary)
  • Pixel-perfect snapshots of large trees unless visual regressions are a real product risk

Arrange–Act–Assert (or Given–When–Then) keeps intent readable. One behavior per test name beats a 200-line "kitchen sink" that fails with an unhelpful assertion at line 180.

Shared fixtures need discipline. A mutable global user created in beforeAll is a flake factory. Prefer factory helpers (createUser({ role: "admin" })) that return fresh state per test. If setup is expensive, invest in transactional tests or isolated truncation — not shared writes across cases.

TDD (red → green → refactor) helps when the interface is clear and the feedback loop is seconds: parsers, calculators, state machines. It is awkward for exploratory UI or when you do not yet know the API. Use it as a tool, not a religion.

Property-based testing (Hypothesis, fast-check) generates many inputs: decode(encode(x)) === x for all x. Excellent for serializers and parsers; weaker for "user can check out" stories.

Snapshot tests compare a large serialized output to a committed blob. They are fast to write and easy to rubber-stamp when they fail. Use for small, stable contracts (GraphQL schema snippets). Avoid full page HTML that changes weekly.

Line coverage is a spotlight, not a scoreboard. Use it to find untested risky files. A payment module at 40% is a problem. A generated protobuf file at 40% is irrelevant. Mutation testing (Stryker, cargo-mutants) introduces small edits and checks whether tests fail. Run it sparingly on high-value modules; it is expensive. Delete tests that only exist to prop up coverage.

Common mistakes

  • Testing implementation because it is easy, skipping boundaries because they need fixtures.
  • Asserting on log line text as a functional contract.
  • Celebrating coverage % while checkout has no integration test.
  • Kitchen-sink tests that fail at line 180 with no story in the name.

Check yourself

  1. Would deleting this assertion change anything you care about? If the answer is "nothing," delete the assertion.

  2. When are snapshot tests a good fit? Small, stable contracts. Not full page HTML that changes weekly.

Key takeaways

  • Assert behavior at boundaries the next engineer cares about.
  • One behavior per test; fresh fixtures per case.
  • TDD, properties, and snapshots are tools with a narrow sweet spot.
  • Coverage finds risky gaps. It is not a scoreboard.

Further learning

Next, treat flakes as an emergency in flaky test policy.