Testing
Overview
Write every test as a specification of behavior, not a description of code. A test verifies behavior through the public interface and must survive any refactor that does not change behavior. Spend test effort by value and risk, not by coverage numbers: a good test proves a capability a caller depends on, reads like a sentence ("user can checkout with valid cart"), and breaks only when that capability breaks.
When to Use
Apply whenever test code is written or judged, by a human or an AI:
- Writing a new unit/integration test, or generating one.
- Reviewing a test — use these rules as the rubric.
- Deciding what to test, or whether something is worth testing.
- Choosing what to mock, or designing code to be testable.
- Diagnosing a brittle or flaky test.
When Not to Use
Governed elsewhere, not by this skill:
- The TDD red-green-refactor process — use the
tdd skill.
- Suite speed, CI sharding, parallelism.
- Coverage gating, thresholds, mutation-testing setup.
- Test framework configuration and environment.
- Testing ROI or defect metrics.
- E2E, visual, load, and security testing techniques.
Strategy — what and how much to test
- Treat the module's public API as the unit. Drive tests through the outward interface; let internal collaborators run for real. Never reach into internals.
- Select by risk, not by completeness. Prioritize business impact × failure probability × change frequency. Critical paths, complex branches, and anything that has produced a bug come first.
- Keep an explicit do-not-test list: trivial getters/setters, framework glue, pure config, throwaway scripts. You cannot and should not test everything; stopping is a decision, not a gap.
- Match test shape to the layer. Front-end: favor integration-style tests through the rendered, user-facing surface. Back-end: many pure-logic unit tests plus one layer of integration against a real test datastore.
Tactics — how to write each case
- Assert observable behavior, never implementation. Verify outputs and effects a caller can see, through the interface — not by reaching past it.
// GOOD: verifies behavior through the interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
expect((await getUser(user.id)).name).toBe("Alice");
});
// BAD: asserts an internal collaboration (implementation detail)
test("checkout calls paymentService.process", async () => {
await checkout(cart, payment);
expect(paymentService.process).toHaveBeenCalledWith(cart.total);
});
// BAD: reaches past the interface to verify
test("createUser saves to DB", async () => {
await createUser({ name: "Alice" });
expect(
await db.query("SELECT * FROM users WHERE name = 'Alice'"),
).toBeDefined();
});
- Derive assertions from the spec/intent, never from the current implementation — the single biggest failure mode when tests are generated. Reading the code for context (types, signatures, real values) is fine, but asserting whatever the code happens to do only snapshots its bugs as correct and breaks on a behavior-preserving refactor.
- Name the test after WHAT, not HOW, and keep one logical assertion per test. "createUser makes user retrievable", not "createUser calls db.insert".
- Design cases systematically, never by ad-hoc guessing: equivalence partitions (one representative each), boundary values (empty, 0, max, null, out-of-range, off-by-one), and error/exception paths are mandatory — not just the happy path. For complex pure logic, assert an invariant and let the tool generate inputs (property-based).
- Mock only at system boundaries: external APIs, time, randomness, and (when needed) the database. Never mock your own code or internal collaborators — that re-couples the test to internal structure and is the top source of brittle tests. Design boundaries to be substitutable: inject dependencies, and prefer SDK-style interfaces over one generic fetcher.
// Mockable: boundary injected
function pay(order, client) {
return client.charge(order.total);
}
// Hard to mock: boundary constructed inside
function pay(order) {
return new StripeClient(KEY).charge(order.total);
}
// Prefer SDK-style: each op independently mockable, one fixed shape
const api = {
getUser: (id) => fetch(`/users/${id}`),
createOrder: (data) => fetch("/orders", { method: "POST", body: data }),
};
// Avoid a generic fetcher: forces conditional logic inside the mock
const api = { fetch: (endpoint, options) => fetch(endpoint, options) };
- Make assertions strong. A test whose only assertion is "did not throw" or
toBeDefined() proves nothing.
- No snapshot tests except for tiny, stable structures — snapshots are where weak assertions hide.
- Use the project's actual test API (not another framework's), and restore/clean mocks between tests so state never leaks across tests.
1---2name: testing-quality3description: Test-case quality rubric — what to test and how to assert it. Use when writing or generating a test, reviewing tests, deciding what is worth testing, choosing what to mock, or diagnosing a brittle/flaky test. Not the TDD process loop (see tdd).4---56# Testing78## Overview910Write every test as a specification of behavior, not a description of code. **A test verifies behavior through the public interface and must survive any refactor that does not change behavior.** Spend test effort by **value and risk, not by coverage numbers**: a good test proves a capability a caller depends on, reads like a sentence ("user can checkout with valid cart"), and breaks only when that capability breaks.1112## When to Use1314Apply whenever test code is written or judged, by a human or an AI:1516- Writing a new unit/integration test, or generating one.17- Reviewing a test — use these rules as the rubric.18- Deciding what to test, or whether something is worth testing.19- Choosing what to mock, or designing code to be testable.20- Diagnosing a brittle or flaky test.2122## When Not to Use2324Governed elsewhere, not by this skill:2526- The TDD red-green-refactor process — use the `tdd` skill.27- Suite speed, CI sharding, parallelism.28- Coverage gating, thresholds, mutation-testing setup.29- Test framework configuration and environment.30- Testing ROI or defect metrics.31- E2E, visual, load, and security testing techniques.3233## Strategy — what and how much to test3435- **Treat the module's public API as the unit.** Drive tests through the outward interface; let internal collaborators run for real. Never reach into internals.36- **Select by risk, not by completeness.** Prioritize business impact × failure probability × change frequency. Critical paths, complex branches, and anything that has produced a bug come first.37- **Keep an explicit do-not-test list:** trivial getters/setters, framework glue, pure config, throwaway scripts. You cannot and should not test everything; stopping is a decision, not a gap.38- **Match test shape to the layer.** Front-end: favor integration-style tests through the rendered, user-facing surface. Back-end: many pure-logic unit tests plus one layer of integration against a real test datastore.3940## Tactics — how to write each case4142- **Assert observable behavior, never implementation.** Verify outputs and effects a caller can see, through the interface — not by reaching past it.4344```typescript45// GOOD: verifies behavior through the interface46test("createUser makes user retrievable", async () => {47 const user = await createUser({ name: "Alice" });48 expect((await getUser(user.id)).name).toBe("Alice");49});5051// BAD: asserts an internal collaboration (implementation detail)52test("checkout calls paymentService.process", async () => {53 await checkout(cart, payment);54 expect(paymentService.process).toHaveBeenCalledWith(cart.total);55});5657// BAD: reaches past the interface to verify58test("createUser saves to DB", async () => {59 await createUser({ name: "Alice" });60 expect(61 await db.query("SELECT * FROM users WHERE name = 'Alice'"),62 ).toBeDefined();63});64```6566- **Derive assertions from the spec/intent, never from the current implementation — the single biggest failure mode when tests are generated.** Reading the code for context (types, signatures, real values) is fine, but asserting whatever the code happens to do only snapshots its bugs as correct and breaks on a behavior-preserving refactor.67- **Name the test after WHAT, not HOW**, and keep **one logical assertion per test**. "createUser makes user retrievable", not "createUser calls db.insert".68- **Design cases systematically**, never by ad-hoc guessing: equivalence partitions (one representative each), boundary values (empty, 0, max, null, out-of-range, off-by-one), and **error/exception paths are mandatory** — not just the happy path. For complex pure logic, assert an invariant and let the tool generate inputs (property-based).69- **Mock only at system boundaries:** external APIs, time, randomness, and (when needed) the database. **Never mock your own code or internal collaborators** — that re-couples the test to internal structure and is the top source of brittle tests. Design boundaries to be substitutable: inject dependencies, and prefer SDK-style interfaces over one generic fetcher.7071```typescript72// Mockable: boundary injected73function pay(order, client) {74 return client.charge(order.total);75}76// Hard to mock: boundary constructed inside77function pay(order) {78 return new StripeClient(KEY).charge(order.total);79}8081// Prefer SDK-style: each op independently mockable, one fixed shape82const api = {83 getUser: (id) => fetch(`/users/${id}`),84 createOrder: (data) => fetch("/orders", { method: "POST", body: data }),85};86// Avoid a generic fetcher: forces conditional logic inside the mock87const api = { fetch: (endpoint, options) => fetch(endpoint, options) };88```8990- **Make assertions strong.** A test whose only assertion is "did not throw" or `toBeDefined()` proves nothing.91- **No snapshot tests except for tiny, stable structures** — snapshots are where weak assertions hide.92- **Use the project's actual test API** (not another framework's), and **restore/clean mocks between tests** so state never leaks across tests.