DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Claude Code TDD: Force Red-Green-Refactor with Hooks & CLAUDE.md (2026)

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.
Enter fullscreen mode Exit fullscreen mode

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"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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[] }
Enter fullscreen mode Exit fullscreen mode

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)