Unit Testing skill for Claude Code

Tests that survive refactors and catch real bugs. Claude stops writing tests that mirror the implementation. It lists observable behaviors first, picks boundary and error inputs on purpose, writes behavior-named arrange-act-assert tests with literal expected values, and breaks the code once to prove each new test can go red. Mocks stay at out-of-process edges, so the suite survives refactors.

  • nameunit-testing
  • size188 lines · ≈2,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and reviews unit tests that pin down behavior rather than implementation, using arrange-act-assert, behavior-named tests, deterministic inputs, and test doubles only at real boundaries. Covers choosing what to test, boundary and edge-case selection, table-driven cases, and test smells. Use when adding tests for a function, class, or module, when tests break on every refactor, when coverage is high but bugs still ship, or when asked "how should I test this".

Try asking

  • Write unit tests for the discount calculator in src/pricing.ts
  • Our tests break every time we refactor. Review tests/orders and tell me why
  • What edge cases am I missing in the tests for parseDuration?

Use it when

  • Adding tests for a function, class, or module
  • Tests break on refactors that did not change behavior
  • Coverage looks high but bugs keep reaching production
  • Reviewing a test file for clarity and value

Skip it when

  • You want the red-green-refactor loop for new code: use tdd-loop instead
  • You need to measure how strong an existing suite is: use mutation-testing

How to check the result

Each new test goes red when the behavior it names is deliberately broken, passes when restored, and the file still passes when run in shuffled order.

The common mistake it prevents

Mocking in-process collaborators the project owns and asserting on their calls, which locks the test to the current implementation instead of the behavior.

~/.claude/skills/unit-testing/SKILL.md

--- name: unit-testing description: Writes and reviews unit tests that pin down behavior rather than implementation, using arrange-act-assert, behavior-named tests,… license: MIT ---

A good unit test fails for exactly one reason: the behavior it names stopped being true. It survives refactors that keep behavior, runs in milliseconds, and reads as a spec. Tests that break when you rename a private method are liabilities, not safety nets.

Ground rules

  • A “unit” is a unit of behavior, not a class. Test through the public API a caller actually uses; never export a private helper just to test it.
  • Every test must be able to fail. Before trusting a new test, break the code under test (flip a condition, return early) and watch it go red.
  • No real clock, randomness, network, filesystem, or environment variables inside a unit test. Inject them or pass them as arguments.
  • One behavior per test. Several expects are fine when they describe one outcome.
  • Match the project’s existing framework, file layout, and naming before introducing anything new.

Workflow

- [ ] 1. Read the code and its callers; list behaviors, not methods
- [ ] 2. Pick the test level for each behavior (table below)
- [ ] 3. Write the test list: happy path, boundaries, errors, invariants
- [ ] 4. Write each test as arrange-act-assert with a behavior name
- [ ] 5. Prove each test can fail
- [ ] 6. Run the suite; check speed and isolation
- [ ] 7. Review for smells before finishing

1. Inventory behaviors

Read the function and at least two call sites. Write one line per observable behavior: return values, thrown errors, state changes visible through the API, calls to out-of-process dependencies (email sent, message published). Internal calls between your own classes are not behaviors.

2. Choose the level

What you are testingLevelDoubles
Pure logic: calculations, parsing, validation, state machinesUnitNone
Orchestration that calls a DB/ORM you controlIntegration with a real DB (container or in-memory engine of the same kind)None for the DB
Code that calls a third-party HTTP APIUnit with HTTP-level stub, plus a contract testStub at the HTTP boundary
Code that emits to a queue, email, or payment providerUnitMock or fake the adapter; assert the outgoing message
Glue code with no branching (wiring, DTO mapping)Covered by a higher-level testSkip a dedicated unit test
UI component behaviorComponent test (see testing-library)Network stub only

If a piece of logic is buried inside I/O-heavy code, extract the decision into a pure function and unit test that. This is the single most effective testability refactor.

3. Build the test list

For each behavior, pick inputs deliberately:

  • Happy path: one representative case.
  • Boundaries: 0, 1, many; empty string/collection; min/max; exactly-at-limit and one-past-limit; first/last page.
  • Equivalence classes: one case per class of input that should behave the same, not ten cases from the same class.
  • Errors: invalid input, missing data, dependency failure. Assert the error type and the meaningful part of the message, not the full string.
  • Domain traps: time zones and DST, currency rounding, Unicode and case folding, floating point, null vs. absent field.

When a function maps many inputs to outputs, use a table instead of copy-pasted tests:

// Vitest / Jest
it.each([
  { qty: 0, expected: 0 },
  { qty: 1, expected: 1000 },
  { qty: 10, expected: 9000 },   // bulk discount kicks in at 10
  { qty: 9, expected: 9000 },    // 9 * 1000, no discount
])('prices $qty items at $expected cents', ({ qty, expected }) => {
  expect(priceInCents(qty)).toBe(expected);
});
# pytest
@pytest.mark.parametrize(("qty", "expected"), [(0, 0), (1, 1000), (9, 9000), (10, 9000)])
def test_price_in_cents(qty, expected):
    assert price_in_cents(qty) == expected

4. Write arrange-act-assert

it('rejects a coupon that expired yesterday', () => {
  // Arrange
  const clock = fixedClock('2026-03-10T12:00:00Z');
  const coupon = aCoupon({ expiresAt: '2026-03-09T23:59:59Z' });

  // Act
  const result = applyCoupon(anOrder(), coupon, clock);

  // Assert
  expect(result).toEqual({ ok: false, reason: 'expired' });
});

Rules:

  • Name = behavior + condition + outcome. rejects a coupon that expired yesterday, test_refund_exceeding_balance_raises. Never test1, testApplyCoupon, or should work.
  • Act is one line. If acting takes several calls, the API is awkward or the test is covering several behaviors.
  • Arrange only what matters. Use builders/factories with defaults so the test shows only the fields that drive the outcome (see test-data-factories).
  • Assert on outcomes: return values, resulting state read through the public API, or messages sent across a boundary. Not on which private method was called.
  • Literal expected values. Write 9000, not qty * unitPrice * 0.9. Recomputing the expectation with the same logic as the code proves nothing.

5. Prove the test can fail

For each new test, temporarily break the implementation in the way the test claims to catch and run only that test. If it stays green, the assertion is wrong or the arrange step never reaches the branch. Revert the break afterwards. For whole-suite strength, use mutation-testing.

6. Run and measure

npx vitest run path/to/file.test.ts     # or: npx jest path/to/file
pytest tests/test_pricing.py -q
go test ./pricing/... -run TestPrice -count=1

Targets: a unit test runs in under ~50 ms; a unit suite of a few thousand tests runs in under a minute locally. Run the file in random order or isolation at least once (vitest --sequence.shuffle, pytest with pytest-randomly installed) to catch shared state.

7. Review for smells

Check every new test against this list. For the full catalog with fixes, read references/test-smells.md when any item applies.

  • Test mirrors the implementation line by line (asserts call order of internals).
  • Mocks for classes the project owns and that have no I/O.
  • Snapshot of a large object where only two fields matter.
  • sleep, real Date.now(), or random data without a fixed seed.
  • Conditional logic or loops in the test body.
  • Shared mutable fixture modified by several tests.
  • Test name and assertion disagree.

Coverage, used correctly

  • Use coverage to find untested behavior, not as a score. Read the uncovered lines and decide if each is a behavior worth a test.
  • Prefer branch coverage over line coverage when the tool supports it.
  • Do not add assertion-free tests to raise the number. A test that executes code without asserting is worse than no test because it creates false confidence.
  • A reasonable gate for new code is 80% branch coverage on changed files, not a global number that punishes legacy modules.

Gotchas

  • toEqual vs toBe / == vs is: object identity checks silently pass or fail for the wrong reason. Use structural equality for values.
  • Floating point: assert with tolerance (toBeCloseTo, pytest.approx) or compute in integer minor units.
  • Async: an unawaited promise lets the test finish before the assertion runs. Return or await it, and use expect.assertions(n) for callbacks.
  • Error assertions: expect(() => fn()).toThrow(...) needs the wrapper function; expect(fn()).toThrow() throws before expect sees it.
  • Tests that pass only in the default time zone. Run once with TZ=Pacific/Auckland.
  • Test helpers with branching logic need their own tests or must be simplified.

Output

When asked to add tests, deliver:

**Behaviors covered:** 6 (list)
**Tests added:** tests/pricing.test.ts (8 cases, 3 table-driven)
**Deliberately not unit tested:** `syncToErp()` — I/O only; covered by integration test X
**Proved failing:** each test red against a mutated implementation, then reverted
**Run:** `npx vitest run tests/pricing.test.ts` — 8 passed in 41 ms

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/test-smells.md 52 lines · 3.4 KB
# Test smell catalog

Read this when reviewing a test suite or when a new test matches an item on the quick
checklist in SKILL.md. Each entry: symptom, why it hurts, fix.

## Coupling to implementation

| Smell | Symptom | Fix |
|---|---|---|
| Overspecified mocks | `expect(repo.save).toHaveBeenCalledWith(...)` for an in-process collaborator the project owns | Assert the resulting state through the public API; keep mocks for out-of-process boundaries only |
| Call-order assertions | Test asserts `a()` was called before `b()` internally | Only assert ordering when it is externally observable (messages sent in order) |
| Testing private methods | Helper exported or accessed via `(obj as any)._calc` | Test through the public method that uses it; if the helper is complex, promote it to its own module with its own public API |
| Mirror test | The test recomputes the expected value with the same formula as the code | Hard-code literal expected values from a worked example |
| Brittle snapshot | 300-line snapshot reviewed by nobody; updated with `-u` on every change | Assert the 2–3 fields that matter, or use small inline snapshots |

## Unclear intent

| Smell | Symptom | Fix |
|---|---|---|
| Mystery guest | Test depends on a fixture file or seeded row defined far away | Build the data in the test with a factory, showing only relevant fields |
| Assertion roulette | Ten unlabeled assertions; failure says `expected true to be false` | Split by behavior or use descriptive matchers and messages |
| Generic names | `test_works`, `it('handles input')` | Rename to behavior + condition + outcome |
| Eager test | One test covers create, update, and delete | One behavior per test; share setup through helpers |
| Irrelevant detail | Arrange block sets 15 fields, 2 matter | Factory defaults; override only what drives the outcome |

## Non-determinism

| Smell | Symptom | Fix |
|---|---|---|
| Real clock | `new Date()` / `datetime.now()` in code under test | Inject a clock or use fake timers / `time-machine` |
| Unseeded randomness | Faker or `Math.random` without a seed | Seed per test run and print the seed on failure |
| Sleep-based waits | `await sleep(500)` before asserting | Await the operation or poll a condition with a timeout |
| Shared mutable state | Module-level list or singleton mutated by several tests | Fresh instance per test; reset in `afterEach` |
| Order dependence | Passes in file order, fails shuffled | Run with shuffle enabled; remove cross-test data reliance |

## Low value

| Smell | Symptom | Fix |
|---|---|---|
| Assertion-free test | Calls code, asserts nothing (coverage padding) | Add a meaningful assertion or delete |
| Tautology | Mocks a function, then asserts the mock returns what it was told | Delete; test the code that consumes the value |
| Testing the framework | Asserts that the ORM saves or that `Array.map` maps | Delete; test your logic |
| Conditional test logic | `if (result) expect(...)` | Make the precondition explicit; the test must always assert |
| Swallowed failures | `try { ... } catch {}` around the act step | Use the framework's error assertions |

## Slow tests

- Unit test over ~100 ms: look for real I/O, large fixtures, or expensive setup in
  `beforeEach` that could be `beforeAll` (only if immutable).
- Whole suite slow: profile with `vitest --reporter=verbose`, `pytest --durations=20`,
  `go test -v` timings. Move I/O-bound tests into an integration project that runs
  separately.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc