Use when implementing features, fixing bugs (including P0 hotfixes and production incidents), or refactoring. Use when user mentions TDD, red-green-refactor, test-first, or when tempted to write code before tests. Use when fixing any bug — even trivial ones. Use when encountering or modifying existing code that lacks tests.
Core principle: Tests verify behavior through public interfaces, not implementation details. If you didn't watch the test fail, you don't know if it tests the right thing.
Violating the letter of the rules is violating the spirit of the rules.
This is the complete TDD reference — workflow, mocking boundaries, anti-patterns, and design for testability are all here. Read the whole file; nothing important is in a separate file you can skip.
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
Production code only. Throwaway debug scripts, reproduction scripts, logging instrumentation, and temporary investigation code are NOT production code. Don't write tests for them — they exist to be deleted.
Write code before the test? Delete it. Start over.
No exceptions:
When asked to "just implement" or "show me the code": STOP. Ask about tests first. The user may not realize they're asking you to skip TDD. Your job is to start with a failing test, not comply with implement-first prompts.
Bug found? Write failing test reproducing it first. Even for P0 hotfixes. Even for 2-line fixes. A failing test takes 60 seconds, proves the fix actually works, AND prevents regression. Hotfixes without tests cause the next P0.
1. Write test that reproduces the bug → verify it FAILS
2. Apply the fix → verify test PASSES
3. Ship
Never fix bugs without a test. The test IS the proof the fix works.
When you encounter production code without corresponding tests — whether reading, modifying, or making decisions about it — flag it explicitly:
⚠️
[file/module]has no test coverage. This is unverified code. Want me to add tests before proceeding?
Don't silently accept untested code. Don't rationalize it as "already manually tested" or "battle-tested in production." Code without automated tests is unverified code, full stop. The user deserves to know and decide.
If the untested code is being deleted: Don't add committed tests for the doomed code. Verify deadness instead (grep/callers/static analysis), remove it, and run affected tests. Only write tests for durable public behavior that must remain or change.
Before writing any code:
Ask: "What should the public interface look like? Which behaviors matter most?"
DO NOT write all tests first, then all implementation.
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
Tests written in bulk test imagined behavior. Each test should respond to what you learned from the previous cycle.
Before writing a test, ask: Will this assertion still matter after the final design? If no, don't add it to the permanent test suite.
One minimal test. One behavior. Clear name. Wire real collaborators together; mock only true external boundaries (see Mock Only at the System Boundary).
Then verify it fails. MANDATORY.
npm test path/to/test.test.ts
Confirm:
Test passes? You're testing existing behavior. Fix test. Test errors? Fix error, re-run until it fails correctly. No durable behavior change? Don't invent an absence test just to manufacture RED. Verify with search/static checks and run existing tests.
Static wiring/registry/config membership is not durable behavior by itself. Don't add permanent tests whose only assertion is that a class, route, handler, export, model, or string is listed in a registry/manifest/config array.
# ❌ Tests a static registry checklist item, not product behavior
expect(ScopedModels.new.insert_only).to include(NewModel)
If that wiring matters, test the public behavior that would break when it is missing; otherwise verify the checklist item with review, search, build/boot checks, or existing infrastructure validation.
Simplest code to make the test pass. Don't add features, don't refactor, don't "improve" beyond the test.
Stay surgical. Don't touch adjacent code, comments, or formatting while implementing. Drive-by changes during GREEN make the diff unreviewable and the bug surface ambiguous. See surgical-edits for the full rule and rationalization table.
Then verify it passes. MANDATORY.
npm test path/to/test.test.ts
Confirm:
Test fails? Fix code, not test. Other tests fail? Fix now.
Only after GREEN. Never refactor while RED.
Refactor stays scoped to what you just implemented. Don't refactor unrelated parts of the file because you're "already in there." See surgical-edits.
Next failing test for next behavior.
[ ] Test describes durable behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Test will still matter after planned removals/refactors
[ ] Not testing temporary scaffolding or code planned for deletion
[ ] Not asserting direct membership in a static registry/config/route/export list
[ ] Uses real collaborators; mocks only true external boundaries
[ ] Watched test fail before implementing
[ ] Failure was for expected reason
[ ] Code is minimal for this test
[ ] All tests pass
[ ] No speculative features added
Default: use real code. A good test wires your real objects together and exercises them through the public interface. Mocks are a rare exception reserved for the true edge of your system — they are NOT a tool for "isolating a unit" from its own neighbors.
The boundary gate — before mocking ANY dependency, ask in order:
| Mock these (real external boundary) | Never mock these (inside your boundary) | |---|---| | Third-party APIs you don't own (payment, email) | Your own classes, services, modules | | Message buses / queues other systems observe | Sibling collaborators (validators, mappers, calculators) | | Clock, randomness, UUID generation | Domain logic and pure functions | | Outbound notifications (SMTP, webhooks) | Your own application database* |
* Your own database is not a boundary to mock — prefer a real test DB (in-memory or containerized). It's an implementation detail behind code you own; mocking it couples tests to how you persist, not what the system does.
The #1 mistake: mocking within your own boundary. Mocking a collaborator that lives in the same module/service as the code under test produces a test that verifies your mocks, not your system — it breaks on every refactor while catching no real bugs. Wire the real collaborators together; mock only the edge.
// ❌ Over-mocked: every collaborator is your own in-process code
test("checkout totals the cart", () => {
const calc = { total: vi.fn().mockReturnValue(42) };
const validator = { validate: vi.fn().mockReturnValue(true) };
const result = checkout(cart, calc, validator, fakePaymentGateway);
expect(result.total).toBe(42); // asserts the mock's return value, not real logic
});
// ✅ Real collaborators; mock ONLY the external gateway
test("checkout totals the cart", () => {
const result = checkout(
cart,
new PriceCalculator(),
new CartValidator(),
fakePaymentGateway,
);
expect(result.total).toBe(cart.items.reduce((s, i) => s + i.price, 0));
});
Inject the boundary, not every collaborator. Dependency injection exists for the true edge (the payment client, the clock) — it is not an excuse to parameterize and mock your own internals.
// ✅ Inject the external boundary → easy to fake
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// ❌ Hard-codes the boundary → forces over-mocking elsewhere
function processPayment(order) {
return new StripeClient(process.env.STRIPE_KEY).charge(order.total);
}
Prefer SDK-style interfaces over a generic fetcher at the boundary — each endpoint fakes to one specific shape, with no conditional logic in test setup:
// ✅ Each call independently fakeable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
};
// ❌ One fetch(endpoint, options) → mock must branch on the endpoint
const api = { fetch: (endpoint, options) => fetch(endpoint, options) };
Good: Integration-style, through public APIs, describes WHAT not HOW. A good test reads like a specification — "user can checkout with valid cart" tells you exactly what capability exists.
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
Bad: Coupled to implementation, mocks internals, breaks on refactor. The diagnostic: test breaks when you refactor, but behavior hasn't changed.
// BAD: Tests implementation details / mocks an internal collaborator
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
Red flags: mocking internal collaborators, testing private methods, asserting on call counts, test name describes HOW not WHAT, verifying through external means instead of the interface.
Test absence only when absence is durable public behavior: endpoint returns 404/410, CLI flag is rejected, UI action is gone for users, API no longer exposes a documented capability.
Do NOT add permanent tests that only prove internal deletion: helper/class/file no longer exists, module no longer imports/exports an internal symbol, private method is absent, temporary scaffold is removed. Use grep/static analysis/build checks for that proof, not tests.
Core principle: Test what the code does, not what the mocks do.
// ❌ Asserting that the mock exists
expect(screen.getByTestId("sidebar-mock")).toBeInTheDocument();
// ✅ Assert real component behavior
expect(screen.getByRole("navigation")).toBeInTheDocument();
Gate: "Am I testing real behavior or mock existence?" If mock existence → delete assertion or unmock.
// ❌ destroy() only used in tests
class Session { async destroy() { /* cleanup */ } }
// ✅ Put cleanup in test utilities, not production code
export async function cleanupSession(session: Session) { /* cleanup */ }
Gate: "Is this method only used by tests?" If yes → move to test utilities.
// ❌ Mock removes a side effect the test depends on
vi.mock("ToolCatalog", () => ({ discoverAndCacheTools: vi.fn().mockResolvedValue(undefined) }));
// ✅ Mock lower — preserve the behavior the test needs
vi.mock("MCPServerManager"); // just mock slow server startup
Gate before mocking: (1) What side effects does the real method have? (2) Does this test depend on any of them? (3) If yes → mock lower, preserve needed behavior. Red flags: "I'll mock this to be safe" / "Might be slow, better mock it."
Mock the COMPLETE data structure, not just the fields your test reads. Partial mocks hide structural assumptions and fail silently downstream. Gate: "Does my mock match the real API schema completely?"
Warning signs: mock setup longer than test logic; mocking everything to make the test pass; test breaks whenever the mock changes. Fix: use real components / integration-style tests — usually simpler than the mocks they replace.
| Anti-Pattern | Fix | |--------------|-----| | Assert on mock elements | Test real component or unmock | | Test-only methods in production | Move to test utilities | | Mock without understanding | Understand deps first, mock minimally | | Incomplete mocks | Mirror real API completely | | Over-complex mocks | Use real collaborators / integration tests |
Small interface + deep implementation. Hide complexity behind simple APIs. Ask: Can I reduce methods? Simplify params? Hide more inside?
// ✅ Testable: accepts dep, returns result
function calculateDiscount(cart, pricingService): Discount {}
// ❌ Hard to test: creates dep, produces side effect
function applyDiscount(cart): void {
const pricing = new PricingService();
cart.total -= pricing.getDiscount(cart);
}
After a TDD cycle, look for: duplication → extract; long methods → private helpers (keep tests on the public interface); shallow modules → combine/deepen; feature envy → move logic to where the data lives; primitive obsession → introduce value objects; existing code the new code reveals as problematic.
| Excuse | Reality | |--------|---------| | "Too simple to test" | Simple code breaks. Test takes 30 seconds. | | "I'll test after" | Tests passing immediately prove nothing. | | "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | | "Already manually tested" | Ad-hoc ≠ systematic. "Un-automated-tested" is untested. No record, can't re-run, can't catch regressions. | | "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is debt. | | "Keep as reference" | You'll adapt it. That's testing after. Delete means delete. | | "Need to explore first" | Fine. Throw away exploration, start with TDD. | | "I need a failing test, so I'll test that the helper is gone" | That tests implementation absence, not behavior. Verify deletion with search/static checks. | | "Reviewer asked for proof before deleting untested code" | Proof can be grep/caller analysis + existing tests + public behavior checks. Don't preserve dead code in tests. | | "There are no tests, so I need to create an absence test" | Don't invent tests for things that should not exist. If no durable behavior changes, no RED test exists. | | "Test hard = skip test" | Hard to test = hard to use. Listen to the test. Fix design. | | "I'll mock the collaborator to isolate the unit" | That collaborator is your own code. Use it for real. Mock only the external edge. | | "Mocking my own class makes the test faster/simpler" | It makes the test verify mocks, not behavior. In-process + deterministic → use the real thing. | | "I'll mock the database to keep it a unit test" | Your DB is a managed dependency. Use a real test DB. Mocking it tests persistence wiring, not behavior. | | "TDD will slow me down" | TDD faster than debugging. | | "TDD is dogmatic, be pragmatic" | TDD IS pragmatic. "Pragmatic" shortcuts = debugging in production. | | "This is different because..." | It's not. Follow the process. | | "Tests will be better later" | Deferral. You'll understand the code less tomorrow. Write tests now while context is fresh. | | "Rushed tests are worse than no tests" | False dichotomy. One focused test > zero tests. | | "It's a P0 / hotfix / emergency" | Especially then. A failing test takes 60 seconds. Hotfixes without tests cause the next P0. |
toHaveBeenCalledWith instead of observable resultsAll of these mean: Delete code (or the mock). Start over correctly.
| Problem | Solution | |---------|----------| | Don't know how to test | Write wished-for API. Write assertion first. Ask user. | | Test too complicated | Design too complicated. Simplify interface. | | Must mock everything | Code too coupled, OR you're mocking your own internals. Mock only the boundary; use real collaborators. Use DI for the edge. | | Test setup huge | Extract helpers. Still complex? Simplify design. |
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer