Test Design Examples
One short bad and good pair per rule in SKILL.md. The examples use Vitest's API; translate them to the repository's runner and conventions.
Does It Earn A Test?
Types already prevent it.
// Bad: the compiler already guarantees this.
it("returns a string", () => {
expect(typeof formatPrice(1999)).toBe("string");
});
// Good: the behaviour has an independent truth (the spec's currency format).
it("formats cents as euros with two decimals", () => {
expect(formatPrice(1999)).toBe("€19.99");
});Trivial unit: test it through its consumer.
// Bad: restates a getter.
it("exposes the customer's name", () => {
expect(new Customer("Ana").name).toBe("Ana");
});
// Good: the behaviour that uses it.
it("lists customers alphabetically by name", () => {
const sorted = sortCustomers([new Customer("Zoe"), new Customer("Ana")]);
expect(sorted.map((customer) => customer.name)).toEqual(["Ana", "Zoe"]);
});Wiring, config and copy: no test.
// Bad: a change detector for a route table.
it("registers the settings route", () => {
expect(routes.find((r) => r.path === "settings")?.component).toBe(SettingsPage);
});
// Good: no test of the table. If reaching settings is a risky flow, one test
// of the flow sees the break, at the level the flow needs.Plan From Named Breaks
Name the break, and whether it is a bug.
// Bad: no break named. It goes red if the copy changes, a deliberate decision.
it("shows the empty state", () => {
expect(screen.getByText("No invoices yet. Create your first one!")).toBeVisible();
});
// Good. Break: the list renders nothing (a bug) when the API returns [].
it("offers to create an invoice when there are none", () => {
renderInvoices({ invoices: [] });
expect(screen.getByRole("button", { name: /create/i })).toBeVisible();
});Highest seam, cheapest level, one level per break.
// Bad: reaches past the seam into a private helper, and the same discount
// rule is asserted again in a component test and an E2E test.
it("applyTier returns 0.1 for gold", () => {
expect((cart as any).applyTier("gold")).toBe(0.1);
});
// Good: the public interface, once, at unit level.
it("gives gold members 10% off", () => {
expect(priceCart({ items: [item(100)], tier: "gold" }).total).toBe(90);
});List the real side effects before mocking.
// Bad: the double drops the audit write the real save() performs.
repo.save = vi.fn().mockResolvedValue(undefined);
// Good: an in-memory substitute that keeps the behaviour that matters, read
// back through the repository's own read path.
const repo = new InMemoryOrderRepo();
await placeOrder(repo, order({ id: "o-1" }));
expect(await repo.history("o-1")).toEqual([{ action: "order.placed" }]);Write Tests That Can Fail
Drive the real interface.
// Bad: sets internal state, then reads it back.
component.state.items = [item()];
expect(component.state.items.length).toBe(1);
// Good: act as the user, assert what the user sees.
await user.click(screen.getByRole("button", { name: "Add to cart" }));
expect(screen.getByRole("status")).toHaveTextContent("1 item");Literal or spec-derived expectations (no mirror assertion).
// Bad: computes the expectation the way the code does; agrees with any bug.
expect(vat(200)).toBe(200 * VAT_RATE);
// Good: the number comes from the spec (18% VAT on €200).
expect(vat(200)).toBe(36);Assert the contract, no less and no more.
// Bad (less): passes even if the wrong user is loaded.
expect(await loadUser("u-1")).toBeDefined();
// Bad (more): breaks on any harmless markup change.
expect(container.innerHTML).toMatchSnapshot();
// Good.
expect(await loadUser("u-1")).toMatchObject({ id: "u-1", email: "a@example.com" });Mock only at boundaries; the mock earns an assertion only when the call is the contract.
// Bad: asserts the stub returned what the test told it to.
pricing.quote = vi.fn().mockReturnValue(42);
expect(pricing.quote()).toBe(42);
// Good: the outbound charge is the contract, so asserting it means something.
await checkout(cart(90), { payments });
expect(payments.charge).toHaveBeenCalledOnce();
expect(payments.charge).toHaveBeenCalledWith({ amount: 9000, currency: "EUR" });Pair every "not called" assertion.
// Bad alone: also passes if sending emails is broken everywhere.
it("does not email unverified users", async () => {
await notify(user({ verified: false }), { mailer });
expect(mailer.send).not.toHaveBeenCalled();
});
// Good: the pair proves the test can tell the difference.
it("emails verified users", async () => {
await notify(user({ verified: true }), { mailer });
expect(mailer.send).toHaveBeenCalledOnce();
});Verify through the interface, not a side channel.
// Bad: checks a log line rather than the behaviour.
await api.post("/orders", body);
expect(logger.info).toHaveBeenCalledWith("order created");
// Good: read it back the way a client would.
const { id } = await api.post("/orders", body);
expect(await api.get(`/orders/${id}`)).toMatchObject({ status: "pending" });Deterministic.
// Bad: a real sleep past the one-hour lifetime, shrunk with a test-only option.
const session = startSession({ lifetimeMs: 1000 });
await new Promise((r) => setTimeout(r, 1100));
expect(session.isExpired()).toBe(true);
// Good: control the clock and keep the real lifetime.
vi.useFakeTimers();
const session = startSession();
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
expect(session.isExpired()).toBe(true);No test-only production code.
// Bad: production exports a reset only the tests call.
export function __resetCacheForTests() { cache.clear(); }
// Good: each test builds its own instance through the real constructor.
const prices = createPriceCache();Name tests after capabilities.
// Bad.
it("calls calculateDiscount", ...);
it("works", ...);
// Good.
it("applies the loyalty discount to orders over €100", ...);See Each New Test Fail
Bug fix: red on the unfixed code.
Bad: write the fix and the test together; the test passes; ship it.
Good: revert the fix, run the focused test, see
"expected 90, received 100" (the reported bug), restore the fix, see green.The two-edit check.
Test: "gives gold members 10% off"
Edit 1, break the behaviour: change the gold rate to 0. -> must go red.
Edit 2, change only the internals: replace the tier switch with a lookup
table giving the same rates. -> must stay green.
A test that stays green on edit 1 protects nothing. A test that goes red on
edit 2 (for example one that spies on the private switch) is a change detector.Report What The Tests Prove
Bad: "Added 14 tests; coverage up from 71% to 83%."
Good: "Cart total: rejects discounts that would make it negative (seen red
by removing the floor). Gold tier: 10% off (seen red test-first).
Not tested: the new button label (copy)."Audit An Existing Suite
Bad: "Deleted 40 low-value tests while adding the feature."
Good (report only, one area):
| Test | Claimed break | Verdict | Evidence |
| --- | --- | --- | --- |
| cart.spec.ts › "has items" | an added item is lost | delete | Reads a getter; "adds an item to the cart" catches the same break through the interface |
| cart.spec.ts › "totals" | wrong total | rewrite | Expected value computed with the code's formula |