Write unit and integration tests that fail for the right reason — behaviour-level assertions, boundary values, the smallest honest double, deterministic setup and teardown. Use when adding tests for new code, pinning a bug before fixing it, or repairing tests that stay green while the code is broken. Not for deciding what to test or at which level, not for browser journeys, and not a way to call a change verified.
A test earns its keep only by failing when the behaviour it names is broken. Most bad tests are not
wrong; they are inert — they assert on what the code does rather than what it must do, so they go
red on every refactor and green through every real defect.
Keep the verbs apart: created is the test file written; executed is a run that happened;
tested is a check that will run again without you; verified is a conclusion about the
system, and a passing unit test does not supply it.
When this fires
Adding coverage for new or changed code, pinning a reported bug before fixing it, or fixing a suite
that passes while the behaviour is wrong. It does not fire for choosing which risks to cover at
which level (test-strategy), nor for end-to-end journeys (e2e-testing).
Procedure
Name the behaviour in one sentence in the caller's terms — "a refund over the original
amount is rejected", not "calls validateAmount". If the sentence needs an internal name to make
sense, you are about to test the implementation.
Make it fail first, for the reason you expect. New test: run it before the code exists or
against the unfixed bug and read the failure message. Existing code: break the line it protects,
confirm red, restore. A test never observed red is an assumption, not a check.
Arrange the minimum, and inline what the test is about. Build entities through a factory or
helper that fills irrelevant fields with defaults, and set the one or two values this test turns
on explicitly in the test body. A reader should see the input that matters without opening a
fixture file.
Assert on the observable outcome — the returned value, the persisted row, the message
published, the error raised. Asserting that a collaborator was called with certain arguments
tests your wiring, and it will keep passing after the collaborator starts doing the wrong thing.
One behaviour per test. A test with several unrelated assertions reports only the first
failure and hides the rest. Several inputs exercising the same behaviour belong in one
parameterised case, not several copies.
Work the boundaries deliberately. For each input: zero, one, many; empty and absent (they are
different); the value at the limit and the value one past it; negative, maximum, and the type's
overflow or precision edge; duplicates; unicode and length limits on text; the timezone or
currency-unit ambiguity. Happy path plus three more happy paths with different numbers is one
test wearing four names.
Choose the smallest honest double. Prefer the real object; then an in-memory fake; then a
stub returning a fixed value; a mock that asserts on interactions last, and only when the
interaction is the behaviour (an email was sent, a payment was captured). Never double the
thing under test. For a real dependency with a real protocol — database, HTTP client, queue —
prefer an integration test against the real thing over a stub of it.
Back every double with a contract check. A stub encodes a belief about what the dependency
returns; if nothing ever executes the real thing, the suite proves only that your code parses
your own fiction. One integration or contract test per doubled boundary is the minimum price.
Make it deterministic by construction. Inject the clock and the random source rather than
freezing globals; no live network; no sleep; no dependence on locale, timezone, filesystem
order or map iteration order; no state shared between tests. Seed randomised data and print the
seed in the failure output.
Isolate and clean up. Each test creates the data it needs and leaves nothing behind —
transaction rollback, a fresh temporary directory, a per-test schema or namespace. Then prove
it: run the single test alone, and run the file in a randomised order.
Test error paths as contracts. Assert the specific type and the identifying part of the
message or code the caller branches on. "Something was raised" passes for a typo in the code
under test.
Name the test so the failure report reads as a sentence — subject, condition, expectation.
The name is what the next person sees at 3am, before the assertion.
Check it is not over-specified. Refactor the internals without changing behaviour — rename a
private helper, reorder independent calls. If the test goes red, it is pinned to the
implementation and will cost more than it catches.
Checklist
Behaviour stated in the caller's terms before writing the test
Test observed failing for the intended reason, not just passing
Assertions on outcomes and state, not on collaborator calls (unless the call is the behaviour)
One behaviour per test; repeated inputs parameterised rather than copied
Smallest honest double used; nothing doubled that could be real
Every doubled boundary has at least one test against the real dependency
Clock, randomness, network, ordering and shared state all controlled
Passes alone, passes in a randomised order, leaves no residue
Error cases assert the specific type or code the caller depends on
Names read as sentences in the failure output
Failure handling
Test passes when you break the code — it does not test what its name claims. Fix the
assertion or delete it; a test that cannot fail is a false green with maintenance cost.
Test is flaky — do not add a sleep or a retry. Find the source: shared state, real time, real
network, ordering, or an actual race in the production code. The last one is a bug, and the flake
is the only evidence you will get of it.
Setup is enormous — that is a design finding, not a test problem. Too many collaborators to
construct means too many dependencies; report it rather than building a scaffold that will rot.
Snapshot or whole-payload assertion — it fails on every unrelated change until someone
regenerates it unread. Use one only where the whole output genuinely is the contract, and assert
on specific fields everywhere else.
Test needs credentials or a shared environment — stop before pointing it at anything shared or
production-like. Writing to a real system or a shared account is outward-facing: ask first,
and prefer a local instance or a disposable record.
Bug cannot be reproduced in a test — say so explicitly. A fix shipped without a failing test
first is a guess; record what was tried and that the regression is unguarded.
Evidence to report
The test command and its actual output, not a summary of it; for each new test, that it was seen
red and why; the mutation check (what was broken, that it went red, that it was restored); which
boundaries are covered and which inputs are not; which dependencies are doubled and where the real
one is exercised instead; and the isolation result — alone and in randomised order. "Tests added
and passing" without a red observation says only that code was written.
1---2name: test-design3description: Write unit and integration tests that fail for the right reason — behaviour-level assertions, boundary values, the smallest honest double, deterministic setup and teardown. Use when adding tests for new code, pinning a bug before fixing it, or repairing tests that stay green while the code is broken. Not for deciding what to test or at which level, not for browser journeys, and not a way to call a change verified.4---56# Test design78A test earns its keep only by failing when the behaviour it names is broken. Most bad tests are not9wrong; they are inert — they assert on what the code does rather than what it must do, so they go10red on every refactor and green through every real defect.1112Keep the verbs apart: **created** is the test file written; **executed** is a run that happened;13**tested** is a check that will run again without you; **verified** is a conclusion about the14system, and a passing unit test does not supply it.1516## When this fires1718Adding coverage for new or changed code, pinning a reported bug before fixing it, or fixing a suite19that passes while the behaviour is wrong. It does not fire for choosing which risks to cover at20which level (`test-strategy`), nor for end-to-end journeys (`e2e-testing`).2122## Procedure23241. **Name the behaviour in one sentence in the caller's terms** — "a refund over the original25 amount is rejected", not "calls validateAmount". If the sentence needs an internal name to make26 sense, you are about to test the implementation.272. **Make it fail first, for the reason you expect.** New test: run it before the code exists or28 against the unfixed bug and read the failure message. Existing code: break the line it protects,29 confirm red, restore. A test never observed red is an assumption, not a check.303. **Arrange the minimum, and inline what the test is about.** Build entities through a factory or31 helper that fills irrelevant fields with defaults, and set the one or two values this test turns32 on explicitly in the test body. A reader should see the input that matters without opening a33 fixture file.344. **Assert on the observable outcome** — the returned value, the persisted row, the message35 published, the error raised. Asserting that a collaborator was called with certain arguments36 tests your wiring, and it will keep passing after the collaborator starts doing the wrong thing.375. **One behaviour per test.** A test with several unrelated assertions reports only the first38 failure and hides the rest. Several inputs exercising the *same* behaviour belong in one39 parameterised case, not several copies.406. **Work the boundaries deliberately.** For each input: zero, one, many; empty and absent (they are41 different); the value at the limit and the value one past it; negative, maximum, and the type's42 overflow or precision edge; duplicates; unicode and length limits on text; the timezone or43 currency-unit ambiguity. Happy path plus three more happy paths with different numbers is one44 test wearing four names.457. **Choose the smallest honest double.** Prefer the real object; then an in-memory fake; then a46 stub returning a fixed value; a mock that asserts on interactions last, and only when the47 interaction *is* the behaviour (an email was sent, a payment was captured). Never double the48 thing under test. For a real dependency with a real protocol — database, HTTP client, queue —49 prefer an integration test against the real thing over a stub of it.508. **Back every double with a contract check.** A stub encodes a belief about what the dependency51 returns; if nothing ever executes the real thing, the suite proves only that your code parses52 your own fiction. One integration or contract test per doubled boundary is the minimum price.539. **Make it deterministic by construction.** Inject the clock and the random source rather than54 freezing globals; no live network; no `sleep`; no dependence on locale, timezone, filesystem55 order or map iteration order; no state shared between tests. Seed randomised data and print the56 seed in the failure output.5710. **Isolate and clean up.** Each test creates the data it needs and leaves nothing behind —58 transaction rollback, a fresh temporary directory, a per-test schema or namespace. Then prove59 it: run the single test alone, and run the file in a randomised order.6011. **Test error paths as contracts.** Assert the specific type and the identifying part of the61 message or code the caller branches on. "Something was raised" passes for a typo in the code62 under test.6312. **Name the test so the failure report reads as a sentence** — subject, condition, expectation.64 The name is what the next person sees at 3am, before the assertion.6513. **Check it is not over-specified.** Refactor the internals without changing behaviour — rename a66 private helper, reorder independent calls. If the test goes red, it is pinned to the67 implementation and will cost more than it catches.6869## Checklist7071- [ ] Behaviour stated in the caller's terms before writing the test72- [ ] Test observed failing for the intended reason, not just passing73- [ ] Assertions on outcomes and state, not on collaborator calls (unless the call is the behaviour)74- [ ] One behaviour per test; repeated inputs parameterised rather than copied75- [ ] Boundaries enumerated: empty, absent, one, many, limit, limit±1, duplicate, unicode76- [ ] Smallest honest double used; nothing doubled that could be real77- [ ] Every doubled boundary has at least one test against the real dependency78- [ ] Clock, randomness, network, ordering and shared state all controlled79- [ ] Passes alone, passes in a randomised order, leaves no residue80- [ ] Error cases assert the specific type or code the caller depends on81- [ ] Names read as sentences in the failure output8283## Failure handling8485- **Test passes when you break the code** — it does not test what its name claims. Fix the86 assertion or delete it; a test that cannot fail is a false green with maintenance cost.87- **Test is flaky** — do not add a sleep or a retry. Find the source: shared state, real time, real88 network, ordering, or an actual race in the production code. The last one is a bug, and the flake89 is the only evidence you will get of it.90- **Setup is enormous** — that is a design finding, not a test problem. Too many collaborators to91 construct means too many dependencies; report it rather than building a scaffold that will rot.92- **Snapshot or whole-payload assertion** — it fails on every unrelated change until someone93 regenerates it unread. Use one only where the whole output genuinely is the contract, and assert94 on specific fields everywhere else.95- **Test needs credentials or a shared environment** — stop before pointing it at anything shared or96 production-like. Writing to a real system or a shared account is outward-facing: **ask** first,97 and prefer a local instance or a disposable record.98- **Bug cannot be reproduced in a test** — say so explicitly. A fix shipped without a failing test99 first is a guess; record what was tried and that the regression is unguarded.100101## Evidence to report102103The test command and its actual output, not a summary of it; for each new test, that it was seen104red and why; the mutation check (what was broken, that it went red, that it was restored); which105boundaries are covered and which inputs are not; which dependencies are doubled and where the real106one is exercised instead; and the isolation result — alone and in randomised order. "Tests added107and passing" without a red observation says only that code was written.
Run npx skillmds@latest add nahid-sparktales/test-design in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Write unit and integration tests that fail for the right reason — behaviour-level assertions, boundary values, the smallest honest double, deterministic setup and teardown. Use when adding tests for new code, pinning a bug before fixing it, or repairing tests that stay green while the code is broken. Not for deciding what to test or at which level, not for browser journeys, and not a way to call a change verified. It is listed under Integrations & APIs on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: docs only. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
nahid-sparktales (@nahid-sparktales) published this skill. Their other Agent Skills are listed on their SkillMD profile.