--- name: test-first description: Use when implementing any feature or bugfix, before writing implementation code --- # Test-First Development ## Overview Write the test first. Observe it fail. Write the minimum code to pass. **Core principle:** If you did not watch the test fail, you have no evidence it validates the correct behavior. **No exceptions. No workarounds. No shortcuts.** ## When to Use **Required:** - New features - Bug fixes - Refactoring - Behavior modifications **Exceptions (confirm with your human partner):** - Disposable prototypes - Generated code - Configuration files Tempted to think "skip test-first just this once"? Stop. That is rationalization. ## The Prime Directive ``` NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST ``` Wrote code before the test? Delete it. Start over. **Zero tolerance:** - Do not keep it as "reference material" - Do not "adjust" it while writing tests - Do not glance at it - Delete means delete Build fresh from tests. Full stop. ## Red-Green-Refactor ```dot digraph tdd_loop { rankdir=LR; red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"]; check_red [label="Fails\ncorrectly?", shape=diamond]; green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"]; check_green [label="All tests\npass?", shape=diamond]; refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"]; next [label="Next", shape=ellipse]; red -> check_red; check_red -> green [label="yes"]; check_red -> red [label="wrong\nfailure"]; green -> check_green; check_green -> refactor [label="yes"]; check_green -> green [label="no"]; refactor -> check_green [label="stay\ngreen"]; check_green -> next; next -> red; } ``` ### RED - Write the Failing Test Write one minimal test that describes the expected behavior. ```typescript test('retries failed operations up to 3 times', async () => { let callCount = 0; const unstableOp = () => { callCount++; if (callCount < 3) throw new Error('transient failure'); return 'done'; }; const outcome = await withRetry(unstableOp); expect(outcome).toBe('done'); expect(callCount).toBe(3); }); ``` Descriptive name, tests actual behavior, single concern ```typescript test('retry works', async () => { const spy = jest.fn() .mockRejectedValueOnce(new Error()) .mockRejectedValueOnce(new Error()) .mockResolvedValueOnce('done'); await withRetry(spy); expect(spy).toHaveBeenCalledTimes(3); }); ``` Vague name, validates mock interactions not real behavior **Criteria:** - One behavior per test - Descriptive name - Uses real code (mocks only when unavoidable) ### Verify RED - Watch It Fail **MANDATORY. Never skip.** ```bash npm test path/to/test.test.ts ``` Confirm: - Test fails (not errors out) - Failure message matches expectations - Fails because the feature is missing (not due to typos) **Test passes?** You are testing existing behavior. Rewrite the test. **Test errors?** Fix the error, re-run until it fails correctly. ### GREEN - Minimal Code Write the simplest possible code that makes the test pass. ```typescript async function withRetry(fn: () => Promise): Promise { for (let attempt = 0; attempt < 3; attempt++) { try { return await fn(); } catch (e) { if (attempt === 2) throw e; } } throw new Error('unreachable'); } ``` Just enough to pass ```typescript async function withRetry( fn: () => Promise, opts?: { maxAttempts?: number; backoffStrategy?: 'linear' | 'exponential'; onAttempt?: (n: number) => void; } ): Promise { // YAGNI } ``` Over-engineered Do not add features, refactor unrelated code, or "improve" beyond what the test requires. ### Verify GREEN - Watch It Pass **MANDATORY.** ```bash npm test path/to/test.test.ts ``` Confirm: - Test passes - Other tests still pass - Output is clean (no errors, no warnings) **Test fails?** Fix the code, not the test. **Other tests fail?** Fix them now. ### REFACTOR - Clean Up Only after green: - Eliminate duplication - Improve naming - Extract helpers Keep tests green. Do not add new behavior. ### Repeat Next failing test for the next behavior. ## Quality Standards | Quality | Good | Bad | |---------|------|-----| | **Focused** | One thing. "and" in the name? Split it. | `test('validates email and domain and whitespace')` | | **Descriptive** | Name explains the behavior | `test('test1')` | | **Intentional** | Demonstrates the desired API | Obscures what the code should do | ## Why Sequence Matters **"I'll write tests afterward to confirm it works"** Tests written after code pass immediately. Passing immediately proves nothing: - May validate the wrong thing - May verify implementation details instead of behavior - May miss edge cases you overlooked - You never watched it catch the failure Test-first forces you to observe the failure, proving the test actually verifies something. **"I already manually tested all the edge cases"** Manual testing is ad hoc. You believe you covered everything but: - No record of what you tested - Cannot rerun when code changes - Easy to skip cases under pressure - "It worked when I tried it" is not comprehensive Automated tests are systematic. They execute identically every time. **"Deleting X hours of work is wasteful"** Sunk cost fallacy. The time is already spent. Your choice now: - Delete and rebuild with test-first (X more hours, high confidence) - Keep it and bolt on tests (30 min, low confidence, probable defects) The real waste is keeping code you cannot trust. Working code without legitimate tests is technical debt. **"Test-first is dogmatic, being pragmatic means adapting"** Test-first IS pragmatic: - Catches defects before commit (faster than debugging later) - Prevents regressions (tests catch breakage immediately) - Documents behavior (tests demonstrate how code works) - Enables refactoring (change freely, tests catch problems) "Pragmatic" shortcuts = debugging in production = slower. **"Tests after achieve the same goals - it's about the spirit not the ritual"** No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" Tests-after are biased by your implementation. You verify what you built, not what is required. You check the edge cases you remember, not ones you discover. Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you did not). 30 minutes of tests after is not test-first. You get coverage, but lose proof that tests work. ## Cognitive Traps | Rationalization | What Is Actually True | |----------------|----------------------| | "Too simple to test" | Simple code breaks. A test takes 30 seconds. | | "I'll test afterward" | Tests passing immediately prove nothing. | | "Tests after achieve the same thing" | Tests-after = "what does this do?" Tests-first = "what should this do?" | | "I already tested manually" | Ad hoc is not systematic. No record, cannot rerun. | | "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | | "Keep as reference, write tests first" | You will adapt it. That is testing after. Delete means delete. | | "Need to explore first" | Fine. Discard the exploration, then start with test-first. | | "Hard to test = I should skip it" | Listen to the test. Hard to test = hard to use. Simplify the design. | | "Test-first will slow me down" | Test-first is faster than debugging. Pragmatic = test-first. | | "Manual testing is faster" | Manual testing does not prove edge cases. You retest every change. | | "Existing code has no tests" | You are improving it. Add tests for what exists. | ## Guardrails - STOP and Start Over - Code before test - Test after implementation - Test passes immediately - Cannot explain why the test failed - Tests added "later" - Rationalizing "just this once" - "I already tested it manually" - "Tests after serve the same purpose" - "It's about the spirit not the ritual" - "Keep as reference" or "adapt existing code" - "Already invested X hours, deleting is wasteful" - "Test-first is dogmatic, I'm being pragmatic" - "This is different because..." **All of these mean: Delete the code. Start over with test-first.** ## Example: Bug Fix **Bug:** Empty email accepted by form **RED** ```typescript test('rejects empty email', async () => { const result = await processForm({ email: '' }); expect(result.error).toBe('Email required'); }); ``` **Verify RED** ```bash $ npm test FAIL: expected 'Email required', got undefined ``` **GREEN** ```typescript function processForm(data: FormData) { if (!data.email?.trim()) { return { error: 'Email required' }; } // ... } ``` **Verify GREEN** ```bash $ npm test PASS ``` **REFACTOR** Extract a validation pipeline for multiple fields if needed. ## Completion Checklist Before declaring work finished: - [ ] Every new function/method has a test - [ ] Watched each test fail before implementing - [ ] Each test failed for the expected reason (feature missing, not typo) - [ ] Wrote minimal code to pass each test - [ ] All tests pass - [ ] Output is clean (no errors, no warnings) - [ ] Tests use real code (mocks only when unavoidable) - [ ] Edge cases and error paths are covered Cannot check every box? You skipped test-first. Start over. ## When Stuck | Problem | Solution | |---------|----------| | Do not know how to write the test | Write the API you wish existed. Write the assertion first. Ask your human partner. | | Test too complicated | Design too complicated. Simplify the interface. | | Must mock everything | Code too coupled. Use dependency injection. | | Test setup is enormous | Extract helpers. Still heavy? Simplify the design. | ## Debugging Integration Bug found? Write a failing test that reproduces it. Follow the test-first cycle. The test both proves the fix and prevents regression. Never fix bugs without a test. ## Testing Anti-Patterns When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: - Validating mock behavior instead of real behavior - Adding test-only methods to production classes - Mocking without understanding dependency chains ## Final Rule ``` Production code -> test exists and failed first Otherwise -> not test-first ``` No exceptions without your human partner's permission.