In this article

🔍 Why TDD is the right leash for an agent

A coding agent is extremely fast at producing plausible code; the bottleneck is verifying it is correct. Test-driven development moves verification before writing: the test is the executable specification, and the agent gets an objective target instead of my description in words.

There is also a more prosaic reason: models tend to declare "done" far too early. With a red suite on the table, "done" stops being a sentence and becomes a state of the system: either the tests pass, or you are not finished. And that is a criterion I can enforce mechanically, as I show shortly with hooks.

The flow I use is the classic red → green → refactor, with one extra rule designed for agents: every phase produces a verifiable output (the test failure in red, the green output in green) that I demand to see in the transcript before moving to the next phase.

The red-green-refactor cycle with the two guardrails
The TDD cycle with Claude Code: a red prompt producing a failing test, a green prompt writing the minimum code to pass, refactoring on a green suite; below, the test-guardian subagent reviews test diffs and the Stop hook runs dotnet test before letting the agent stop.

Every phase produces a verifiable output; the hook and the subagent close the loopholes.

⚠️ How an agent cheats on tests (and what I must prevent)

Before mounting guardrails I need to know what I am defending against. When an agent faces a red test it cannot make pass, the shortcuts I have seen most often are three: deleting or skipping the test, weakening the asserts (an `Assert.Equal` that becomes `Assert.NotNull`), or updating the expected value to match the implementation's wrong output.

Then there is a fourth, subtler shortcut: overfitting — code that passes exactly the tested cases with a chain of `if`s, without implementing the general rule. The weapon against it is writing more cases than the agent can conveniently memorise and reviewing the diff.

The overall strategy: the written rules in `CLAUDE.md` tell the agent what not to do, the Stop hook makes it impossible to finish with a red suite, and the guardian subagent reviews the test diffs after every green phase.

  • Deleted or skipped tests: the guardian greps the diff for `Skip =` and commented-out `[Fact]`.
  • Weakened asserts: `Equal` → `NotNull`/`True` is the classic tell.
  • Rewritten expected values: the test adapts to the bug instead of the other way round.
  • Overfitting: passes the test's cases, not the rule; fought with more cases and review.

🏗️ The tutorial project: PriceRules, a pricing Minimal API

To keep the focus on the method I use a small but real domain: PriceRules, a Minimal API with a single `POST /quote` endpoint that prices a cart. The business rules are perfect for TDD because they are full of edge cases: a 10% volume discount on lines with at least 10 units, percentage coupons applied after the volume discount, unknown coupons ignored.

The stack is my usual one: .NET 8, Minimal API with no Controllers, typed records for the DTOs, LINQ for the calculations, xunit for the unit tests and `WebApplicationFactory` for the two integration tests on the endpoint.

The complete code, together with the hook and the subagent already configured, lives in the companion repo on GitHub: it is linked under every snippet and in the dedicated block near the end of the article.

The pricing rules to implement in TDD
POST /quote  { items: [{sku, quantity, unitPrice}], coupon? }

R1  subtotal = sum of quantity * unitPrice
R2  lines with quantity >= 10  ->  -10% on that line (volume-10pct)
R3  known coupon (WELCOME10, BLACKFRIDAY20) -> % on total AFTER R2
R4  unknown coupon -> ignored, no error
R5  empty cart -> 400 Bad Request
Five rules, each born from a test. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

📁 Step 1 · Create the solution, the API and the test project

I start from the skeleton: a solution with the API project and the test project. Nothing special, but I do it before opening Claude Code: scaffolding is not agent work, and a project that already compiles empty avoids burning the first ten minutes of the session.

Solution scaffolding
$ dotnet new sln -n PriceRules
$ dotnet new web   -o src/PriceRules.Api
$ dotnet new xunit -o tests/PriceRules.Tests
$ dotnet sln add src/PriceRules.Api tests/PriceRules.Tests
$ dotnet add tests/PriceRules.Tests reference src/PriceRules.Api

# for the integration tests on the endpoint
$ dotnet add tests/PriceRules.Tests package Microsoft.AspNetCore.Mvc.Testing

$ dotnet test   # green: 0 tests, but it compiles

dotnet test · Microsoft Learn

Scaffolding by hand, before opening the agent. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

📜 Step 2 · CLAUDE.md: the loop rules, written for an agent

The `CLAUDE.md` at the repo root is the contract Claude Code loads on every session. Here I do not describe the project: I describe the process. The three key rules: never production code without a red test demanding it, never edit a test to make it pass, and the obligation to paste the `dotnet test` output at every phase.

Wording matters: "if a test looks wrong, stop and say so" gives the agent a legitimate way out — without it, a cornered model tends to invent its own shortcut.

CLAUDE.md · the TDD rules (excerpt)
## The loop
1. **Red** - write ONE failing test for the next behaviour.
   Run `dotnet test` and paste the failure BEFORE any production code.
2. **Green** - write the MINIMUM code that makes it pass. Paste the output.
3. **Refactor** - clean up with the suite green. Run `dotnet test` again.

## Hard rules
- Never edit a test to make it pass (no weakened asserts,
  no updated expected values, no deleted or skipped tests).
  If a test looks wrong, STOP and say so instead.
- One behaviour per test; names describe the behaviour.
- After every green phase, dispatch the `test-guardian` subagent.

CLAUDE.md and memory · Claude Docs

The process before the project. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🔴 Step 3 · Red: the prompt that asks for the test only

The red phase is the most delicate one: I must stop the agent from writing the implementation straight away, which is its natural instinct. The prompt is explicit: the test only, for one rule, and I expect to watch it fail.

For rule R2 (volume discount) the resulting test pins the numbers down without ambiguity: 10 units at €5 plus one unit at €100 must give a subtotal of 150, a discount of 5 and a total of 145. If the test passes on the first run it is an alarm, not good news: it means it is not testing anything new.

PricingServiceTests.cs · the red test for the volume discount
[Fact]
public void Quote_TenOrMoreUnitsOfOneSku_AppliesVolumeDiscountOnThatLine()
{
    var request = new QuoteRequest(
    [
        new CartItem("BULK", 10, 5m),   // 50 -> 10% off = 5
        new CartItem("OTHER", 1, 100m), // no discount
    ]);

    var response = _pricing.Quote(request);

    Assert.Equal(150m, response.Subtotal);
    Assert.Equal(5m, response.Discount);
    Assert.Equal(145m, response.Total);
    Assert.Contains("volume-10pct", response.AppliedRules);
}
One behaviour, pinned numbers, four asserts. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🧾 Verifying red: the failure output in the transcript

Before authorising the green phase I demand the proof of red: the `dotnet test` output with the failure, pasted in the transcript. It is a habit that costs ten seconds and closes an entire genre of cheating: a test I have never seen fail proves nothing.

dotnet test · the red suite, as it should be
$ dotnet test --nologo

  Failed Quote_TenOrMoreUnitsOfOneSku_AppliesVolumeDiscountOnThatLine
  Assert.Equal() Failure: Values differ
  Expected: 5
  Actual:   0

Failed!  - Failed: 1, Passed: 3, Skipped: 0, Total: 4
The proof of red: without this output, the green phase does not start. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🟢 Step 4 · Green: the minimum code that passes the test

Now yes: I ask for the implementation, constrained to the bare minimum. The resulting `PricingService` is pure LINQ: subtotal as a sum, volume discount with `Where` + `Sum`, coupon looked up in a case-insensitive dictionary and applied to the net amount after the volume discount.

Note the style: no `for` loops, single-statement `if`s without braces, early returns implied by the structure. Those are the style rules I keep in my `base-rules`, and the agent respects them because they are written into the context, not because it remembers them.

PricingService.cs · the minimal implementation
public QuoteResponse Quote(QuoteRequest request)
{
    var rules = new List<string>();
    var subtotal = request.Items.Sum(i => i.Quantity * i.UnitPrice);

    var volumeDiscount = request.Items
        .Where(i => i.Quantity >= VolumeThreshold)
        .Sum(i => i.Quantity * i.UnitPrice * VolumeRate);
    if (volumeDiscount > 0)
        rules.Add("volume-10pct");

    var couponDiscount = 0m;
    if (request.Coupon is not null && Coupons.TryGetValue(request.Coupon, out var rate))
    {
        couponDiscount = (subtotal - volumeDiscount) * rate;
        rules.Add($"coupon-{request.Coupon.ToLowerInvariant()}");
    }

    var discount = Math.Round(volumeDiscount + couponDiscount, 2);
    var total = Math.Round(subtotal - discount, 2);
    return new QuoteResponse(subtotal, discount, total, rules);
}
LINQ, typed records, no loops. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🧼 Step 5 · Refactor on a green suite (and the Minimal API endpoint)

With a green suite I can refactor without fear: extract the `VolumeThreshold` and `VolumeRate` constants, fix the names. There is a single rule: after every touch, `dotnet test`. Refactoring with a safety net is the dividend TDD pays for the rest of the project's life.

The endpoint is the least interesting piece — and that is how it should be: the logic lives in the unit-tested service, the endpoint only does binding and empty-cart validation (rule R5), covered by the two integration tests with `WebApplicationFactory`.

QuoteEndpoints.cs · the Minimal API endpoint
public static class QuoteEndpoints
{
    // POST /quote - price a cart: subtotal, applied rules, final total.
    public static void MapQuoteEndpoints(this IEndpointRouteBuilder app) =>
        app.MapPost("/quote", (QuoteRequest request, PricingService pricing) =>
        {
            if (request.Items is null || request.Items.Count == 0)
                return Results.BadRequest(new { message = "Cart is empty" });

            return Results.Ok(pricing.Quote(request));
        });
}
The logic lives in the service; the endpoint does binding and validation. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🛡️ Step 6 · The test-guardian subagent: who watches the tests

The first guardrail is a dedicated subagent: `test-guardian`, a markdown file in `.claude/agents/` with read-only tools. After every green phase I send it to read the diff and hunt for the three classic tamperings: deleted or skipped tests, weakened asserts, rewritten expected values.

Its strength is the clean context: the guardian has not seen the main agent's struggle to make the test pass, so it does not share its bias — it only reads the diff and judges. It is the same logic as human code review, at the cost of one command.

.claude/agents/test-guardian.md
---
name: test-guardian
description: Read-only reviewer that checks whether the latest changes
  weakened, deleted or trivialised any test. Use after every green phase.
tools: Read, Grep, Glob, Bash
---

You are the test guardian of a TDD codebase. You never write code.

1. Run `git diff` and read every change under `tests/`.
2. Flag as VIOLATION: deleted/skipped tests, weakened asserts,
   expected values edited to match the implementation output.
3. Reply `OK - tests intact` or `VIOLATION` with file and line.

Subagents · Claude Docs

Clean context, read-only tools, binary verdict. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🪝 Step 7 · The Stop hook: the gate that blocks done

The second guardrail is the deterministic one. Claude Code hooks are shell commands attached to lifecycle events; the Stop event fires every time the agent tries to end its turn. If the script exits with exit code 2, the turn does not end: the stderr goes back to the agent as an instruction, and the agent goes back to work.

My `tdd-gate.sh` does one thing: it runs `dotnet test` and, if the suite is red, exits with 2 reporting the last twenty lines of output. Result: "done" with red tests no longer exists, by definition, whatever the model believes.

.claude/hooks/tdd-gate.sh + settings.json
#!/usr/bin/env bash
# Stop hook: block "done" while tests are red.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0

output=$(dotnet test --nologo 2>&1)
if [ $? -ne 0 ]; then
  {
    echo "TDD gate: the test suite is RED - you are not done."
    echo "$output" | tail -20
  } >&2
  exit 2   # blocks the Stop, stderr goes back to the agent
fi
exit 0

# .claude/settings.json
# { "hooks": { "Stop": [ { "hooks": [ { "type": "command",
#     "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/tdd-gate.sh" } ] } ] } }

Hooks · Claude Docs

Exit 2 on the Stop event: the turn does not end while the suite is red. Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

🧪 Step 8 · Trial by fire: I break a test and try to stop

To trust a gate I need to watch it fire. The drill: I hand-edit a value in `PricingService` (the volume discount from 10% to 15%), then ask the agent to wrap up. The gate runs the suite, finds red and bounces the Stop: the agent receives the failure output and goes back to fixing — without me touching the keyboard.

That is the difference between asking for discipline and enforcing it: CLAUDE.md can be ignored in a moment of model creativity, exit code 2 cannot.

The Stop hook in action: red, bounce, green
Terminal sequence of the Stop hook drill: the agent tries to end its turn, the tdd-gate.sh gate runs dotnet test, the suite is red, the hook exits with code 2 and the agent is sent back to fix the code until the suite is green again.

The turn only ends when dotnet test passes: the gate proven live.

📦 GitHub repo

All the code from this article is published in a public repository: the complete PriceRules solution (Minimal API + 8 xunit tests), the `CLAUDE.md` with the TDD rules, the `tdd-gate.sh` Stop hook already registered in `.claude/settings.json` and the `test-guardian` subagent. Clone, `dotnet test`, and the project is green; open Claude Code in the folder and the gate is already active.

No external services required: no database, no Docker — the solution runs in-process with the .NET 8 SDK alone.

Clone and try the repo
$ git clone https://github.com/fscamuzzi/tdd-claude-code-dotnet.git
$ cd tdd-claude-code-dotnet
$ dotnet test    # Passed! - Failed: 0, Passed: 8

# the API, live
$ dotnet run --project src/PriceRules.Api
$ curl -s http://localhost:5000/quote \
    -H "Content-Type: application/json" \
    -d '{"items":[{"sku":"BULK","quantity":10,"unitPrice":10}],"coupon":"WELCOME10"}'

tdd-claude-code-dotnet · GitHub

Full code in the repo: https://github.com/fscamuzzi/tdd-claude-code-dotnet

✅ Final checklist: the method in eight moves

Let me recap the method, which works identically with Codex or any other agent that supports project rules and hooks: TDD provides the objective definition of "done", the agent's tooling makes it non-negotiable.

From here you can level up: the gate can get stricter (minimum coverage, `dotnet format --verify-no-changes`), the guardian can also run on SubagentStop, and the same rules can move into CI as a second line of defence.

TDD with Claude Code in 8 moves
  1. 01
    Scaffold by handsln + api + xunit, compiles empty
  2. 02
    CLAUDE.mdthe process, not the project
  3. 03
    Red promptonly ONE test, for ONE rule
  4. 04
    Proof of redfailure output in the transcript
  5. 05
    Green promptthe minimum that passes
  6. 06
    Refactoron green, test after every touch
  7. 07
    test-guardiansubagent on the test diff
  8. 08
    Stop hookexit 2 while the suite is red

The definition of 'done' is dotnet test: written in the rules, enforced by the gate.

Frequently asked questions about TDD with Claude Code

Why do TDD with an AI agent instead of having it write tests afterwards?

A test written afterwards tends to photograph what the code does, not what it should do — and an agent writing code and tests together can get both wrong in a consistent way. The test written first is an independent specification: it defines the target and makes "done" verifiable with one command.

How do I stop Claude Code from editing tests to make them pass?

On three levels: the explicit rule in CLAUDE.md (with the escape hatch "if a test looks wrong, stop and say so"), the test-guardian subagent reviewing test diffs after every green phase, and human review of the diff. Experience says you need the whole ladder: the written rule alone is not enough.

What exactly does a Stop hook with exit code 2 do?

The Stop event fires when the agent tries to end its turn. If the attached command exits with code 2, the stop is blocked and the script's stderr goes back to the agent as an instruction: in my case the red dotnet test output, with the order to fix the code without weakening the tests.

Does the gate slow the session down? dotnet test runs on every Stop.

On this project the suite runs in under two seconds, so no. On large suites, have the gate run only the fast tests (for example with a category filter) and leave the full suite to CI: the gate must stay fast to stay acceptable.

Does this method also work with Codex or other agents?

The method does: red-green-refactor with proof of red is tool-independent, and the project rules live in AGENTS.md instead of CLAUDE.md. What changes are the native guardrails: hooks with exit code 2 and subagents with a separate context are Claude Code features; with other agents the second line of defence remains CI.

Let's talk

If this topic is relevant to you, write to me: comparing notes on code and AI is always time well spent.

All articles