Overview
Write the test first. Watch it fail. Write the minimal code to pass.
Core principles: If you haven't seen the test fail, you don't know whether it tests the right thing.
**Violating the letter of the rules is violating the spirit of the rules.**
When to use
Always:
- New features
- Bug fixes
- Refactoring
- Behavior changes
Exceptions (ask your human partner):
- Throwaway prototypes
- Generated code
- Configuration files
Thinking "skip TDD this time"? Stop. That's rationalization.
The iron law
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
Wrote code before the test? Delete it. Start over.
No exceptions:
- Don't keep it as a "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete
Implement the new test. Period.
Red-green-refactor
digraph tdd_cycle {
rankdir=LR;
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Verify fails\ncorrectly", shape=diamond];
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Verify passes\nAll green", shape=diamond];
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Next", shape=ellipse];
red -> verify_red;
verify_red -> green [label="yes"];
verify_red -> red [label="wrong\nfailure"];
green -> verify_green;
verify_green -> refactor [label="yes"];
verify_green -> green [label="no"];
refactor -> verify_green [label="stay\ngreen"];
verify_green -> next;
next -> red;
}
Red - write a failing test
Write a minimal test showing what should happen.
<good>
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, one thing </good>
<bad>
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 mocks instead of code </bad>
Requirements:
- One behavior
- Clear name
- Real code (no mocks unless unavoidable)
Verify red - watch it fail
**Mandatory. Never skip.**
npm test path/to/test.test.ts
Confirm:
- Test fails (not errors)
- Failure message is expected
- Fails due to missing feature (not a typo)
**Test passes?** You're testing existing behavior. Fix the test.
**Test errors?** Fix the error, rerun, until it fails correctly.
Green - minimal code
Write the simplest code to pass the test.
<good>
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 </good>
<bad>
async function retryOperation<T>(
fn: () => Promise<T>,
options?: {
maxRetries?: number;
backoff?: 'linear' | 'exponential';
onRetry?: (attempt: number) => void;
}
): Promise<T> {
// YAGNI
}
Over-engineering </bad>
Don't add features, refactor other code, or make "improvements" beyond the test.
Verify green - watch it pass
**Mandatory.**
npm test path/to/test.test.ts
Confirm:
- Test passes
- Other tests still pass
- Output is clean (no errors, warnings)
**Test fails?** Fix the code, not the test.
**Other tests fail?** Fix immediately.
Refactor - clean up
Only after green:
- Remove duplication
- Improve names
- Extract helpers
Keep tests green. Don't add behavior.
Repeat
Next failing test for the next feature.
Good tests
| Quality | Good | Bad |
|---|---|---|
| Minimal | One thing. "And" in the name? Split it. | test('validates email and domain and whitespace') |
| Clear | Name describes behavior | test('test1') |
| State the intent | The required API | Obscures the program code execution |
Why order matters
"I will write tests afterward to verify whether it works"
Tests written after the code passes pass immediately. Passing immediately does not prove anything:
- May test the wrong thing
- May test the implementation, not the behavior
- May miss edge cases you forgot
- You never encounter the bug it catches
Test-first forces you to see the test fail, proving it actually tests something.
"I have manually tested all edge cases"
Manual testing is ad hoc. You think you tested everything, but:
- There is no record of what you tested
- Cannot be rerun after the code changes
- Easy to forget cases under pressure
- "I tried it and it worked" ≠ comprehensive
Automated tests are systematic. They run the same way every time.
"Deleting X hours of work is a waste"
Sunk cost fallacy. The time has already passed. Your choices now:
- Delete and rewrite using TDD (a few extra hours, high confidence)
- Keep it and add tests afterward (30 minutes, low confidence, possible bugs)
"Waste" is keeping code you do not trust. Working code without real tests is technical debt.
"TDD is dogmatic; being pragmatic means adapting"
TDD is pragmatic:
- Finds bugs before committing (faster than debugging afterward)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use the code)
- Enables refactoring (change freely, tests catch breaks)
"Practical" shortcuts = debugging in production = slower.
"Test after reaching the same goal - it is the spirit, not the ritual"
No. Testing after answers "What does this do?" Test-first answers "What should this do?"
Tests afterward are biased by your implementation. You test what you built, not what is required. You verify edge cases you remembered, not ones you discovered.
Test-first forces discovering edge cases before implementation. Testing - verifying you remembered everything (you did not).
≠ TDD after 30 minutes of testing. You got insurance, but lost the work of proving the tests.
Common rationalizations
| Sorry | Reality |
|---|---|
| "Too simple to test" | Simple code breaks. Testing takes 30 seconds. |
| "I will test afterward" | Tests passing immediately does not prove anything. |
| "Test after reaching the same goal" | Tests-after = "What does this do?" Test-first = "What should this do?" |
| "Already manually tested" | Ad hoc ≠ systematic. No record, cannot rerun. |
| "Deleting X hours is a waste" | Sunk cost fallacy. Keeping code with default verification is technical debt. |
| "Keep it for reference, write the test first" | You will get used to it. What follows is the test; deleting means deleting. |
| "Need to explore first" | Fine. Give up exploring, start with TDD. |
| "Trying hard to test = unclear design" | Listen to the tests. Hard to test = hard to use. |
| "TDD will slow me down" | TDD is faster than debugging. Pragmatic = test first. |
| "Manual testing is faster" | A manual checklist cannot prove edge cases. You will retest every change. |
| "The existing code has no tests" | You are improving it. Add tests for the existing code. |
Red flags - stop and start over
- Code before the test
- Test after implementation
- The test passes immediately
- Cannot explain why the test failed
- Add tests "later"
- Rationalize "just this once"
- "I already tested it manually"
- "Test after achieving the same goal"
- "It's about the spirit, not the ritual"
- "Keep it for reference" or "adapt the existing code"
- "Already spent X hours on it, deleting it is too wasteful"
- "TDD is dogmatic, I'm pragmatic"
- "This is different because..."
**All of this means: delete the plan code. Start with TDD. **
Example: bug fix
Bug: Accept empty email
Red
test('rejects empty email', async () => {
const result = await submitForm({ email: '' });
expect(result.error).toBe('Email required');
});
Verify red
$ npm test
FAIL: expected 'Email required', got undefined
Green
function submitForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
Verify green
$ npm test
PASS
Refactor If needed, extract validation for multiple fields.
Verification checklist
Before marking the work complete:
- Every new function/method has a test
- Observe each test fail before implementation
- Each test fails for the expected reason (missing feature, not a spelling error)
- Write the minimum code to pass each test
- All tests pass
- Output is clean (no errors, warnings)
- Tests use real code (mock only when unavoidable)
- Edge cases and errors covered
Can't get through all the interruptions? You skipped TDD. Start over.
When stuck
| Problem | Solution |
|---|---|
| Don't know how to test | Write the desired API. Write the assertions first. Ask your human partner. |
| Test is too complicated | The design is too complicated. Simplify the interface. |
| Have to mock everything | The code is too tightly coupled. Use dependency injection. |
| Test setup is huge | Extract helpers. Still complicated? Simplify the design. |
Debugging integration
Found a bug? Write a failing test to reproduce it. Follow the TDD cycle. The test proves it is fixed and prevents regression.
Never fix a bug without a test.
Testing anti-patterns
When adding new mocks or testing utilities, please read the common pitfalls section in @testing-anti-patterns.md:
- Testing mock behavior instead of real behavior
- Adding test-only methods to production classes
- Mocking without understanding dependencies
Final rule
Production code → test exists and failed first
Otherwise → not TDD
No exceptions without your human partner's permission.