Test-Driven Development (TDD)
Overview
Write tests first. Watch it fail. Write the minimal code to make it pass.
Core Principle: If you don't see the test fail, you don't know if it's testing the right thing.
Breaking the letter of the rule is breaking the spirit of the rule.
When to Use
Always use for:
- New features
- Bug fixes
- Refactoring
- Behavior changes
Exceptions (ask your human partner):
- One-off prototypes
- Generated code
- Configuration files
Thinking "just skip TDD this once"? Stop. That's making excuses.
Non-Negotiable Rules
No production code is written without a failing test
Wrote code before tests? Delete it. Start over.
No exceptions:
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete
Start over from the test. Period.
Red-Green-Refactor
dot
digraph tdd_cycle {
rankdir=LR;
red [label="Red\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Verify correct failure", shape=diamond];
green [label="Green\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Verify pass\nAll green", shape=diamond];
refactor [label="Refactor\nClean up code", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Next", shape=ellipse];
red -> verify_red;
verify_red -> green [label="Yes"];
verify_red -> red [label="Incorrect\nfailure"];
green -> verify_green;
verify_green -> refactor [label="Yes"];
verify_green -> green [label="No"];
refactor -> verify_green [label="Keep\ngreen"];
verify_green -> next;
next -> red;
}
Red - Write Failing Test
Write a minimal test that demonstrates the desired behavior.
<Good>
```typescript
test('retries failed operations 3 times', async () => {
let attempts = 0;
const operation = () => {
attempts++;
if (attempts < 3) throw new Error('fail');
return 'success';
};
const result = await retryOperation(operation);
expect(result).toBe('success');
expect(attempts).toBe(3);
});
Clear name, tests real behavior, focuses on one thing
</Good>
<Bad>
```typescript
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
Vague name, tests mock instead of code
</Bad>
Requirements:
- One behavior
- Clear name
- Use real code (only mock when absolutely necessary)
Verify Red - Watch It Fail
Must execute. Never skip.
bash
npm test path/to/test.test.ts
Confirm:
- The test fails (not errors)
- Failure message matches expectations
- Failure reason is missing functionality (not a typo)
Test passed? You're testing existing behavior. Modify the test.
Test errored? Fix the error, rerun until it fails correctly.
Green - Minimal Code
Write the simplest code to make the test pass.
<Good>
```typescript
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}
```
Just enough to pass the test
</Good>
<Bad>
```typescript
async function retryOperation<T>(
fn: () => Promise<T>,
options?: {
maxRetries?: number;
backoff?: 'linear' | 'exponential';
onRetry?: (attempt: number) => void;
}
): Promise<T> {
// YAGNI
}
```
Over-engineered
</Bad>
Don't add features, refactor other code, or make "improvements" beyond what the test requires.
Verify Green - Watch It Pass
Must execute.
bash
npm test path/to/test.test.ts
Confirm:
- The test passes
- Other tests still pass
- Output is clean (no errors, warnings)
Test failed? Modify the code, not the test.
Other tests failed? Fix immediately.
Refactor - Clean Up Code
Only refactor after green:
- Eliminate duplication
- Improve naming
- Extract helper functions
Keep tests green. Don't add behavior.
Repeat
Write the next failing test for the next feature.
Good Tests
| Characteristic | Good | Bad |
|---|
| Minimal | Tests only one thing. Got "and" in the name? Split it. | test('validates email and domain and whitespace')
|
| Clear | Name describes behavior | |
| Shows Intent | Demonstrates expected API | Hides what the code should do |
Why Order Matters
"I'll write tests afterward to verify"
Tests written afterward pass immediately. Passing immediately proves nothing:
- Might be testing the wrong thing
- Might be testing implementation instead of behavior
- Might miss edge cases you forgot
- You never see it catch bugs
Writing tests first forces you to see the test fail, proving it actually tests something.
"I've manually tested all edge cases"
Manual testing is temporary. You think you tested everything, but:
- No test record
- Can't rerun after code changes
- Easy to forget under pressure
- "I tried it and it works" ≠ comprehensive testing
Automated tests are systematic. They run the same way every time.
"Deleting X hours of work is a waste"
Sunk cost fallacy. The time is already spent. Your choices now:
- Delete and rewrite with TDD (another X hours, high confidence)
- Keep and add tests later (30 minutes, low confidence, potential bugs)
The "waste" is keeping code you can't trust. Working code without real tests is technical debt.
"TDD is too dogmatic, being pragmatic means flexibility"
TDD is pragmatic:
- Catches bugs before commit (faster than debugging later)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Supports refactoring (modify with confidence, tests catch breaks)
"Pragmatic" shortcuts = debugging in production = slower.
"Adding tests later achieves the same goal—it's the spirit that matters, not the ritual"
No. Tests added later answer "What does this code do?" Tests written first answer "What should this code do?"
Tests added later are biased by your implementation. You test what you built, not what the requirements demand. You verify edge cases you remember, not those you discover.
Writing tests first forces you to discover edge cases before implementation. Tests added later verify you remembered all cases (you didn't).
30 minutes of tests added later ≠ TDD. You get coverage, but lose proof the tests are effective.
Common Excuses
| Excuse | Reality |
|---|
| "It's too simple to test" | Simple code can still have bugs. Tests take 30 seconds. |
| "I'll add tests later" | Tests that pass immediately prove nothing. |
| "Adding tests later achieves the same goal" | Tests added later = "What does this do?" Tests first = "What should this do?" |
| "I've already tested manually" | Temporary testing ≠ systematic testing. No record, no reproducibility. |
| "Deleting X hours of work is a waste" | Sunk cost fallacy. Keeping unvalidated code is technical debt. |
| "Keep it as reference, then write tests first" | You'll adapt it. That's adding tests later. Delete means delete. |
| "I need to explore first" | You can. After exploring, throw it away and start with TDD. |
| "Hard to test = unclear design" | Listen to the tests. Hard to test = hard to use. |
| "TDD slows me down" | TDD is faster than debugging. Pragmatic = write tests first. |
| "Manual testing is faster" | Manual testing can't prove edge cases. You have to retest every time you change code. |
| "Existing code has no tests" | You're improving it. Add tests for existing code. |
Red Flags - Stop, Start Over
- Wrote code before tests
- Added tests after implementation
- Test passes immediately
- Can't explain why the test failed
- "I'll add tests later"
- Convincing yourself "just this once"
- "I've tested manually"
- "Adding tests later achieves the same goal"
- "It's the spirit that matters, not the ritual"
- "Keep as reference" or "adapt existing code"
- "I've spent X hours, deleting is a waste"
- "TDD is too dogmatic, I'm being pragmatic"
- "This situation is different because..."
All of the above mean: Delete the code. Start over with TDD.
Example: Bug Fix
Bug: Empty email is accepted
Red
typescript
test('rejects empty email', async () => {
const result = await submitForm({ email: '' });
expect(result.error).toBe('Email required');
});
Verify Red
bash
$ npm test
FAIL: expected 'Email required', got undefined
Green
typescript
function submitForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
Verify Green
Refactor
If needed, extract validation logic to support multiple fields.
Verification Checklist
Before marking work complete:
Can't check all boxes? You skipped TDD. Start over.
When Stuck
| Problem | Solution |
|---|
| Don't know how to test | Write the API you expect. Write assertions first. Ask your human partner. |
| Tests are too complex | Design is too complex. Simplify the interface. |
| Have to mock everything | Code is too tightly coupled. Use dependency injection. |
| Test setup is too bulky | Extract helper functions. Still complex? Simplify design. |
Debugging Integration
Found a bug? Write a failing test that reproduces the bug. Follow the TDD cycle. The test both proves the fix works and prevents regressions.
Never fix a bug without a test.
Testing Anti-Patterns
When adding mocks or testing tools, read @testing-anti-patterns.md to avoid common pitfalls:
- Testing mock behavior instead of real behavior
- Adding test-only methods to production classes
- Using mocks without understanding dependencies
Final Rule
Production code → Test exists and failed first
Else → Not TDD
No exceptions without permission from your human partner.