The problem with AI-assisted TDD isn't that Claude can't write tests — it's that without constraints, Claude writes implementation first, then writes tests that match that implementation. You get 100% test coverage and zero confidence the tests catch anything.
This guide shows how to configure Claude Code so TDD isn't a guideline you might forget — it's the only workflow available.
Full guide: stacknotice.com/blog/claude-code-tdd-workflow-2026
The Fix: CLAUDE.md Rules
## Development workflow — STRICT TDD (always follow this order)
1. **RED**: Write a failing test first. Run `npm test` — confirm it FAILS.
Do NOT write implementation before this step.
2. **GREEN**: Write the minimum code to make the test pass.
Run `npm test` — confirm ALL tests pass.
3. **REFACTOR**: Clean up without changing behavior.
Run tests after every change.
**Rules:**
- Never write implementation without a failing test first.
- Never write more implementation than the current test requires.
- One cycle at a time: RED → GREEN → REFACTOR before the next feature.
Hooks: Auto-Run Tests After Every Write
// .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm test -- --reporter=verbose 2>&1 | tail -20"
}
]
}
]
}
}
Every time Claude writes a file, your test suite runs automatically. Claude immediately sees whether the test is red or green — no manual npm test needed.
A Real TDD Cycle
Prompt:
Build a `validatePassword` function. Follow TDD per CLAUDE.md:
write the test first, confirm RED, then implement.
Requirements: 8+ chars, 1 uppercase, 1 number.
Returns { valid: boolean, errors: string[] }
Claude writes the test first:
// src/lib/password.test.ts
describe('validatePassword', () => {
it('returns valid for a strong password', () => {
expect(validatePassword('SecurePass1')).toEqual({ valid: true, errors: [] })
})
it('fails when too short', () => {
const result = validatePassword('Sh0rt')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Password must be at least 8 characters')
})
it('fails when no uppercase', () => {
const result = validatePassword('lowercase1')
expect(result.errors).toContain('Password must contain at least one uppercase letter')
})
it('fails when no number', () => {
const result = validatePassword('NoNumbers')
expect(result.errors).toContain('Password must contain at least one number')
})
})
Hook fires → test output in context: RED (module not found).
Claude writes minimum implementation:
// src/lib/password.ts
export function validatePassword(password: string) {
const errors: string[] = []
if (password.length < 8) errors.push('Password must be at least 8 characters')
if (!/[A-Z]/.test(password)) errors.push('Password must contain at least one uppercase letter')
if (!/[0-9]/.test(password)) errors.push('Password must contain at least one number')
return { valid: errors.length === 0, errors }
}
Hook fires → GREEN. All 4 tests pass.
Testing React Components
// src/components/PasswordInput.test.tsx
describe('PasswordInput', () => {
it('renders input with label', () => {
render(<PasswordInput label="Password" />)
expect(screen.getByLabelText('Password')).toBeInTheDocument()
})
it('toggles password visibility', async () => {
const user = userEvent.setup()
render(<PasswordInput label="Password" />)
const input = screen.getByLabelText('Password')
expect(input).toHaveAttribute('type', 'password')
await user.click(screen.getByRole('button', { name: /show password/i }))
expect(input).toHaveAttribute('type', 'text')
})
it('shows strength indicator', async () => {
const user = userEvent.setup()
render(<PasswordInput label="Password" />)
await user.type(screen.getByLabelText('Password'), 'StrongPass1')
expect(screen.getByText('Strong')).toBeInTheDocument()
})
})
Write test → confirm RED → implement → GREEN. Same cycle, same discipline.
Where TDD Proves Its Value: Regressions
Three weeks later, add a special character requirement:
Add: at least one special character. Write the failing test first.
Claude adds one test → it fails → Claude adds one line to the validator → all 5 tests pass. The existing tests are a regression net. If the new code breaks the uppercase check, you see it immediately.
Handling Claude Skipping TDD
If mid-session Claude writes implementation before tests:
Stop. You wrote implementation before the test.
Delete `src/lib/feature.ts`. Write the test first, confirm RED, then implement.
Explicit course corrections work reliably. If it keeps happening, /compact to clear context.
Pre-commit Hook
Block commits when tests fail:
# .husky/pre-commit
#!/bin/sh
npm test -- --run
Commit your .claude/settings.json alongside CLAUDE.md — every team member gets the same TDD enforcement automatically.
Full guide with Route Handler testing, component testing patterns, and multi-step examples: stacknotice.com/blog/claude-code-tdd-workflow-2026
Top comments (0)