DEV Community

brian austin
brian austin

Posted on

Claude Code TDD: write tests first, let Claude implement, watch them pass

Claude Code TDD: write tests first, let Claude implement, watch them pass

Test-driven development with Claude Code flips the usual workflow — you write the failing test, Claude writes the implementation, your hook runs the tests automatically. Here's the exact setup.

The TDD loop with Claude Code

You write test → Claude implements → hooks run tests → Claude fixes failures → repeat
Enter fullscreen mode Exit fullscreen mode

This works because Claude Code's PostToolUse hook can run your test suite on every file write.

Step 1: Write a failing test first

Tell Claude:

I'm doing TDD. Do NOT implement anything yet. Just read this spec:

Function: calculateShipping(weight, destination)
- Under 1kg to domestic: $3.50
- Over 1kg to domestic: $3.50 + ($1.20 * kg)
- International: $12.00 flat
- Invalid weight (<=0): throw Error('Invalid weight')
Enter fullscreen mode Exit fullscreen mode

Then write your test:

// shipping.test.js
const { calculateShipping } = require('./shipping');

describe('calculateShipping', () => {
  test('domestic under 1kg', () => {
    expect(calculateShipping(0.5, 'domestic')).toBe(3.50);
  });

  test('domestic over 1kg', () => {
    expect(calculateShipping(2, 'domestic')).toBe(5.90);
  });

  test('international flat rate', () => {
    expect(calculateShipping(5, 'international')).toBe(12.00);
  });

  test('invalid weight throws', () => {
    expect(() => calculateShipping(0, 'domestic')).toThrow('Invalid weight');
  });
});
Enter fullscreen mode Exit fullscreen mode

Run it — it fails (no implementation yet). Good.

Step 2: Configure the PostToolUse hook

In .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "cd $CLAUDE_PROJECT_DIR && npm test -- --passWithNoTests 2>&1 | tail -20"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Now every time Claude writes a file, the test suite runs automatically. Claude sees the output.

Step 3: Ask Claude to implement

Now implement shipping.js to make all tests pass.
The test runner will fire automatically on each file write.
Don't stop until all tests pass.
Enter fullscreen mode Exit fullscreen mode

Claude writes the implementation, sees the test output in the hook, fixes failures, iterates. You watch green pass line by line.

Step 4: The self-healing pattern

For complex implementations, add this to your hook command:

"command": "cd $CLAUDE_PROJECT_DIR && npm test 2>&1 | tail -30 && echo 'EXIT:' $?"
Enter fullscreen mode Exit fullscreen mode

The EXIT: 0 or EXIT: 1 tells Claude whether to continue fixing or stop.

Real example: REST API TDD

// api.test.js - write this BEFORE any implementation
describe('POST /api/users', () => {
  test('creates user with valid data', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com', name: 'Test User' });
    expect(res.status).toBe(201);
    expect(res.body.id).toBeDefined();
  });

  test('rejects duplicate email', async () => {
    // first insert
    await request(app).post('/api/users').send({ email: 'dupe@example.com', name: 'First' });
    // second insert same email
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'dupe@example.com', name: 'Second' });
    expect(res.status).toBe(409);
  });

  test('validates email format', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'not-an-email', name: 'Test' });
    expect(res.status).toBe(400);
  });
});
Enter fullscreen mode Exit fullscreen mode

Then:

Implement the POST /api/users endpoint. Tests will run on every write.
All 3 tests must pass before you stop.
Enter fullscreen mode Exit fullscreen mode

Claude implements the route, the middleware, the validation — iterating until green.

Why this works better than asking Claude to "write tests"

When you write tests first:

  1. You define the contract — Claude can't guess wrong about edge cases
  2. Failures are specific — "expected 5.90 received 5.9" is a precision bug, not a logic bug
  3. Claude self-corrects — hooks show Claude exactly what's broken without you explaining

When Claude writes tests after implementation, it tests what it built — not what you wanted.

The coverage hook

Add coverage reporting to the same hook:

"command": "cd $CLAUDE_PROJECT_DIR && npm test -- --coverage --coverageThreshold='{\"global\":{\"branches\":80}}' 2>&1 | tail -30"
Enter fullscreen mode Exit fullscreen mode

Now Claude won't stop until branch coverage hits 80%. It'll add edge case tests itself to meet the threshold.

Rate limits during long TDD sessions

Long TDD sessions hit Claude's rate limits hard — the loop of write → test → fix → repeat burns through tokens fast.

If you're running sessions longer than 30-40 minutes of active TDD, you'll hit the "rate limited" pause. The ANTHROPIC_BASE_URL trick routes through a proxy that gives you continuous access:

export ANTHROPIC_BASE_URL=https://simplylouie.com/api
claude  # runs normally, no rate limit interruptions
Enter fullscreen mode Exit fullscreen mode

$2/month at simplylouie.com — 7-day free trial, no charge today.

The full TDD setup checklist

# 1. Install test framework
npm install --save-dev jest

# 2. Add test script to package.json
# "test": "jest --watchAll=false"

# 3. Create .claude/settings.json with PostToolUse hook
# (see hook JSON above)

# 4. Write failing test
# 5. Tell Claude: "implement X, tests run automatically, stop when green"
# 6. Watch it work
Enter fullscreen mode Exit fullscreen mode

The loop runs itself. Your job is writing the spec as tests, then reviewing the implementation once it's green.

Top comments (0)