| 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
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
</Good>
<Bad>
```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.
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.
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
test('rejects empty email', async () => {
const result = await processForm({ email: '' });
expect(result.error).toBe('Email required');
});
Verify RED
$ npm test
FAIL: expected 'Email required', got undefined
GREEN
function processForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
}
Verify GREEN
$ npm test
PASS
REFACTOR
Extract a validation pipeline for multiple fields if needed.
Completion Checklist
Before declaring work finished:
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.