Unit tests verify a function. Integration tests verify the wiring. End-to-end tests verify the user-visible outcome. A healthy test strategy uses all three, but in different proportions than the classical pyramid suggests — and adds a fourth weapon (property-based) that finds bugs the others structurally miss.
Pyramids, Honeycombs, and Trophies
| Shape | Recommended mix | Failure it warns against |
|---|---|---|
| Pyramid | 70% unit / 20% integration / 10% E2E | E2E-heavy suites that are slow and flaky |
| Honeycomb | Few unit / many integration / few E2E | Mock-heavy units that pass while integration fails |
| Trophy | Many integration / some unit / few E2E | Refactors that break units but not behaviour |
The honest truth: there is no universal mix. Pick the shape that maximises confidence per minute of test runtime for your codebase. A library is unit-heavy; a CRUD service is integration-heavy; a CLI is often E2E-heavy. Measure runtime, measure regressions caught, then trade off.
Property-Based Testing
A unit test asserts one example. A property-based test asserts an invariant and lets a generator throw thousands of inputs at it. The test states what must always be true; the harness finds the input that falsifies it — and shrinks that input to the smallest reproducer.
property: sort(list) preserves length
AND sort(list) is monotonic non-decreasing
AND sort(sort(list)) == sort(list)
harness: 10,000 random lists of ints, drift against these invariants
A property a database sort might satisfy: sort(xs ++ ys) == sort(sort(xs) ++ sort(ys)) for every pair of lists. A property a hash map satisfies: get(put(m, k, v), k) == v for every map, key, and value. The harness tries tens of thousands of inputs; when one breaks the property, it shrinks to [] or [0, 0] — the minimal failing case.
Property-based tests catch off-by-one and edge-of-domain bugs that exampled tests miss because no one wrote an example for the input that broke. Use them for pure logic (parsers, sorts, state machines, transformations over algebraic structures).
Contract Testing (Consumer-Driven)
A microservice test that mocks its upstream/downstream dependencies can pass while real integration breaks the moment a producer ships. A contract test fixes this: each consumer writes its expectations against a shared contract; each producer verifies it satisfies every contract before deploying.
Consumer writes: "I call POST /users with {name, email}; I expect 201 with {id} or 409"
Producer verifies: it passes everyone's contracts before it can ship
This is the anti-pattern decoder for “we have tests but integration still breaks in prod”. Tools like Pact automate this; the engineering practice is older than the tools. The principle is: the consumer owns the contract, not the producer.
Mutation Testing
Coverage tells you which lines ran. Mutation testing tells you whether your tests fail when your code changes — a much truer measure.
The tool: take every line of production code, apply small mutations (+ → -, == → !=, if (x) → if (true)), and re-run the tests. Each mutation that survives is a hole — a code change that nothing catches.
production code: if (i < list.length)
mutation: if (i <= list.length)
tests pass? yes → surviving mutant → coverage lied
no → caught mutant → test would have caught the regression
Mutation testing is expensive. Use it as a periodic audit, not a per-commit gate: a single run on critical modules exposes which “covered” lines actually have a behaviour test watching them.
Snapshot Tests: Cheap and Fragile
A snapshot test captures the serialised output of a component (typically: rendered React tree, JSON API response, generated HTML) and re-asserts it on every run. Cheap to write, expensive to maintain, because every refactor updates the snapshot whether or not it changed behaviour.
Snapshot failure rules:
- Snapshots must be reviewed in diffs. A blind
--updateSnapshotis the failure mode; it converts the test into a no-op. - Snapshots must name what they are testing:
renders signup form with validation errors, notrenders correctly. - Snapshots are not assertions — they are change detectors. Their drift and not their existence is the value.
Use them sparingly for output stability proofs; prefer property-based tests for pure logic, prefer integration tests for behaviour.
Test Readability
A test file is more often read than written, just like production code. Three habits compound:
- Name tests by behaviour, not by method:
returns -1 when target is missingreads better thantestBinarySearch1. The name is your documentation. - Arrange / Act / Assert (AAA): every test has the same three-section shape, separated by blank lines. Deviations are loud.
- One assertion per test is a heuristic, not a law — the real law is “one behaviour per test”. Three related post-conditions assert one thing.
A mutant in if (x > 0) return compute(x) should kill exactly one test, not three.
Practice Trajectory
- Take a unit of pure logic and convert one example-style test to a property with shrinking (try
fast-checkfor JS,hypothesisfor Python). Note the first bug the fuzzer finds. - Add a mutation-testing run (
strykerfor JS,mutmutfor Python) to a critical module. Surviving mutants reveal “covered” lines nobody actually tests. - Write a consumer-driven contract test for one microservice and a producer verifying it. Ship both.
- Audit one snapshot-heavy suite: read the last ten
--updateSnapshotcommits. Were they drift or behaviour change? Divide into each category. - Refactor one AAA-violating test in your own code into AAA shape. Describe what the new shape made obvious.
When It’s the Right Tool
| Situation | Takeaway |
|---|---|
| Pure logic with edge cases | Property-based testing beats example-based |
| Microservices with breaking-prod history | Consumer-driven contract tests |
| “Coverage at 90% but bugs still ship” | Mutation testing reveals covered-by-name-only lines |
| Generated output (HTML, JSON, JSX) | Snapshot tests as change detectors, not assertions |
| Reviewing a test PR | Behaviours first, mechanics second — a passing flaky test is worse than a failing useful one |