Testing philosophy
The shared definition of "good test" that every skill in this set which proposes or reviews tests leans on. It distils Kent Dodds' testing writing and generalizes it past his JS/React context to any language and any layer.
The one principle
The more your tests resemble the way your software is used, the more confidence they can give you. (Kent C. Dodds)
Every rule below is a corollary. The principle is already language- and layer-agnostic; "the way your software is used" just resolves to a different surface in each context. When a testing decision is unclear, return to this sentence.
Pillar 1: Behavior over implementation details
An implementation detail is anything "users of your code will not typically use, see, or even know about." A test bound to one fails in both directions at once:
- False negative on refactor: you change internals without changing behavior, and the test breaks anyway. It cries wolf.
- False positive on breakage: you break the actual behavior, and the test stays green because it was watching the mechanism, not the result.
Dodds' framing: a test that drives your code differently from how real users drive it becomes a third user you never wanted, now you keep the end user, the calling developer, and the test happy.
"User" generalizes by layer. Assert only on what that user can observe; drive the code only through the surface that user touches:
| Layer |
The user |
Assert on (observable) |
Do NOT touch (implementation) |
| Library / module |
the calling developer |
return values, thrown errors, public types, emitted events |
private fields/methods, internal helpers, internal data-structure choices |
| Backend / API |
the HTTP/RPC client |
status codes, response bodies, headers, state visible through the API, domain events |
ORM internals, service method names, query shapes, internal DTOs |
| Frontend / UI |
the person clicking |
rendered text, roles, what appears/disappears after an interaction |
component state vars, hook internals, CSS class names, child-component names |
| CLI |
the person at the prompt |
stdout/stderr, exit code, files written |
internal flag parsing, function call order |
Concrete tells of an implementation-detail test (stack-independent, flag any of these):
- Asserts on private fields, unexported functions, internal state, or internal data-structure shape.
- Asserts a particular internal function/method was called, spy / mock-call-count assertions on a collaborator you own.
- Mocks a collaborator you own, rather than mocking only at a true external boundary (network, disk, clock, OS process, third-party service).
- Its name describes a mechanism (
calls set_index with 0) instead of a behavior (shows the first slide on load).
The acid test: "If I refactor the internals but keep the public contract identical, does this test break?" If yes, it tests the wrong thing. Pair it with: "Does this assert what a real user observes?" If no, same problem.
Pillar 2: The Testing Trophy, and the e2e floor
Four layers, bottom to top. Height is confidence; height is also cost/time. Testing is "return on investment where return is confidence and investment is time."
| Layer |
What it is |
Mock how much |
| Static |
types + lint, correctness for free (tsc, cargo check, go vet, Dialyzer) |
n/a |
| Unit |
one piece in isolation |
dependencies mocked |
| Integration |
several units working together, the default tier |
mock only external boundaries |
| E2E |
the whole assembled system |
"as little as possible" |
"Write tests. Not too many. Mostly integration" puts most effort in the middle, because that is the best confidence-per-effort and most regressions live between units, not inside them.
The correction that matters: "mostly integration" is not "skip e2e." Two opposite violations, and most reviews only catch the first:
- Too much e2e: every scenario duplicated as a slow, flaky full-stack test that could be an integration test. Flag it; push it down the trophy.
- No e2e at all for the critical path: every layer tested in isolation, but nothing proves they connect. The quieter, more common gap. Flag it just as hard.
The e2e necessity rule: every user-facing feature needs at least one end-to-end (or highest-practical) test that exercises its critical happy path through the real, assembled system, one or two paths, not one per scenario. E2e proves the wiring; integration proves the behavior; unit proves the tricky pure logic; static proves the shapes. Unit + integration coverage with no wiring proof for a user-facing feature is a gap.
E2e does not require a browser, it generalizes by surface and by stack:
| Surface |
What "e2e" means |
Typical tooling |
| Frontend app |
browser-driven: load then act then see the result |
Playwright, Cypress |
| Backend service |
hit the running service over its real protocol against a real (test) DB; assert response + persisted state |
Go net/http/httptest, Rust reqwest + spawned server, Elixir Phoenix ConnCase / Phoenix.LiveViewTest, supertest |
| CLI |
invoke the built binary as a subprocess; assert stdout / exit code / files written |
Rust assert_cmd, Go os/exec in _test.go, shell harness |
| Library |
consume the published public API exactly as a downstream user would, nothing internal stubbed |
Rust tests/ integration dir, Go external _test package, Elixir ExUnit against the public module, Vitest against the built entrypoint |
Runners by stack are just the mechanism: cargo test / cargo nextest (Rust), go test (Go), mix test / ExUnit (Elixir), Vitest / Jest / Playwright (TS/JS), pytest (Python). The tier matters, not the runner.
How skills use this
When proposing tests (planning, ATDD): write tests behavior-first against the public surface; default to integration; reserve unit tests for pure functions with tricky logic; and include at least one e2e/full-stack happy-path test for any user-facing feature. Never aim at a coverage number, coverage is a side effect of testing the right behaviors.
When reviewing tests: run both acid-test questions on every test; flag the implementation-detail tells above; check both e2e directions, overuse and the missing-critical-path gap.
Anti-patterns
- Treating "mostly integration" as license to ship a user-facing feature with zero e2e coverage.
- Asserting on mock call counts / spies for collaborators you own, then calling it a behavior test.
- Test names that describe the mechanism (
calls X) rather than the outcome the user observes.
- Snapshot tests that get rubber-stamped on update, they assert "it changed," not "it is correct."
- Chasing a coverage percentage instead of covering the behaviors that matter.
- Assuming e2e means a browser: for a backend or CLI the e2e is a subprocess/HTTP test, and skipping it "because there is no UI" is the gap, not an excuse.
Source
Kent C. Dodds: Testing Implementation Details, The Testing Trophy and Testing Classifications, Write tests. Not too many. Mostly integration.. Generalized here to any language and layer.
1---2name: testing-philosophy3description: Defines what a good test is: behavior over implementation details, and the Testing Trophy with a hard floor on end-to-end coverage for user-facing features. Stack-agnostic, any language (TS/JS, Rust, Go, Elixir, Python, ...) and any layer (frontend, backend, CLI, library). REQUIRED BACKGROUND for skills that plan or review tests (implementation-review, heist, review-pass). Use when proposing, writing, or reviewing automated tests, or when the user asks whether tests are good, what to test, why a test is brittle, or whether e2e is needed.4---56# Testing philosophy78The shared definition of "good test" that every skill in this set which proposes or reviews tests leans on. It distils Kent Dodds' testing writing and generalizes it past his JS/React context to any language and any layer.910## The one principle1112> **The more your tests resemble the way your software is used, the more confidence they can give you.** (Kent C. Dodds)1314Every rule below is a corollary. The principle is already language- and layer-agnostic; "the way your software is used" just resolves to a different surface in each context. When a testing decision is unclear, return to this sentence.1516## Pillar 1: Behavior over implementation details1718An **implementation detail** is anything *"users of your code will not typically use, see, or even know about."* A test bound to one fails in both directions at once:1920- **False negative on refactor**: you change internals without changing behavior, and the test breaks anyway. It cries wolf.21- **False positive on breakage**: you break the actual behavior, and the test stays green because it was watching the mechanism, not the result.2223Dodds' framing: a test that drives your code differently from how real users drive it becomes *a third user you never wanted*, now you keep the end user, the calling developer, **and** the test happy.2425**"User" generalizes by layer.** Assert only on what *that* user can observe; drive the code only through the surface *that* user touches:2627| Layer | The user | Assert on (observable) | Do NOT touch (implementation) |28|---|---|---|---|29| Library / module | the calling developer | return values, thrown errors, public types, emitted events | private fields/methods, internal helpers, internal data-structure choices |30| Backend / API | the HTTP/RPC client | status codes, response bodies, headers, state visible *through* the API, domain events | ORM internals, service method names, query shapes, internal DTOs |31| Frontend / UI | the person clicking | rendered text, roles, what appears/disappears after an interaction | component state vars, hook internals, CSS class names, child-component names |32| CLI | the person at the prompt | stdout/stderr, exit code, files written | internal flag parsing, function call order |3334**Concrete tells of an implementation-detail test** (stack-independent, flag any of these):3536- Asserts on private fields, unexported functions, internal state, or internal data-structure shape.37- Asserts a particular internal function/method *was called*, spy / mock-call-count assertions on a collaborator **you own**.38- Mocks a collaborator you own, rather than mocking only at a true external boundary (network, disk, clock, OS process, third-party service).39- Its name describes a mechanism (`calls set_index with 0`) instead of a behavior (`shows the first slide on load`).4041**The acid test:** *"If I refactor the internals but keep the public contract identical, does this test break?"* If yes, it tests the wrong thing. Pair it with: *"Does this assert what a real user observes?"* If no, same problem.4243## Pillar 2: The Testing Trophy, and the e2e floor4445Four layers, bottom to top. Height is **confidence**; height is also **cost/time**. Testing is "return on investment where return is confidence and investment is time."4647| Layer | What it is | Mock how much |48|---|---|---|49| **Static** | types + lint, correctness for free (`tsc`, `cargo check`, `go vet`, Dialyzer) | n/a |50| **Unit** | one piece in isolation | dependencies mocked |51| **Integration** | several units working together, **the default tier** | mock only external boundaries |52| **E2E** | the whole assembled system | "as little as possible" |5354"Write tests. Not too many. **Mostly integration**" puts most effort in the middle, because that is the best confidence-per-effort and most regressions live *between* units, not inside them.5556**The correction that matters: "mostly integration" is not "skip e2e."** Two opposite violations, and most reviews only catch the first:57581. **Too much e2e**: every scenario duplicated as a slow, flaky full-stack test that could be an integration test. Flag it; push it down the trophy.592. **No e2e at all for the critical path**: every layer tested in isolation, but nothing proves they connect. The quieter, more common gap. Flag it just as hard.6061**The e2e necessity rule:** *every user-facing feature needs at least one end-to-end (or highest-practical) test that exercises its critical happy path through the real, assembled system*, one or two paths, not one per scenario. E2e proves the wiring; integration proves the behavior; unit proves the tricky pure logic; static proves the shapes. Unit + integration coverage with no wiring proof for a user-facing feature **is a gap**.6263**E2e does not require a browser, it generalizes by surface and by stack:**6465| Surface | What "e2e" means | Typical tooling |66|---|---|---|67| Frontend app | browser-driven: load then act then see the result | Playwright, Cypress |68| Backend service | hit the running service over its real protocol against a real (test) DB; assert response + persisted state | Go `net/http/httptest`, Rust `reqwest` + spawned server, Elixir Phoenix `ConnCase` / `Phoenix.LiveViewTest`, supertest |69| CLI | invoke the built binary as a subprocess; assert stdout / exit code / files written | Rust `assert_cmd`, Go `os/exec` in `_test.go`, shell harness |70| Library | consume the published public API exactly as a downstream user would, nothing internal stubbed | Rust `tests/` integration dir, Go external `_test` package, Elixir ExUnit against the public module, Vitest against the built entrypoint |7172Runners by stack are just the mechanism: `cargo test` / `cargo nextest` (Rust), `go test` (Go), `mix test` / ExUnit (Elixir), Vitest / Jest / Playwright (TS/JS), pytest (Python). The tier matters, not the runner.7374## How skills use this7576**When proposing tests** (planning, ATDD): write tests behavior-first against the public surface; default to integration; reserve unit tests for pure functions with tricky logic; and **include at least one e2e/full-stack happy-path test for any user-facing feature.** Never aim at a coverage number, coverage is a side effect of testing the right behaviors.7778**When reviewing tests:** run both acid-test questions on every test; flag the implementation-detail tells above; check **both** e2e directions, overuse *and* the missing-critical-path gap.7980## Anti-patterns8182- Treating "mostly integration" as license to ship a user-facing feature with zero e2e coverage.83- Asserting on mock call counts / spies for collaborators you own, then calling it a behavior test.84- Test names that describe the mechanism (`calls X`) rather than the outcome the user observes.85- Snapshot tests that get rubber-stamped on update, they assert "it changed," not "it is correct."86- Chasing a coverage percentage instead of covering the behaviors that matter.87- Assuming e2e means a browser: for a backend or CLI the e2e is a subprocess/HTTP test, and skipping it "because there is no UI" is the gap, not an excuse.8889## Source9091Kent C. Dodds: [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details), [The Testing Trophy and Testing Classifications](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications), [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests). Generalized here to any language and layer.