In questo articolo
- 🔍 Perché il TDD è il guinzaglio giusto per un agente
- ⚠️ Come un agente bara sui test (e cosa devo impedire)
- 🏗️ Il progetto del tutorial: PriceRules, una Minimal API di pricing
- 📁 Passo 1 · Creare soluzione, progetto e test project
- 📜 Passo 2 · CLAUDE.md: le regole del loop, scritte per un agente
- 🔴 Passo 3 · Red: il prompt che chiede solo il test
- 🧾 Verificare il red: l'output del fallimento nel transcript
- 🟢 Passo 4 · Green: il minimo codice che fa passare il test
- 🧼 Passo 5 · Refactor a suite verde (e l'endpoint in Minimal API)
- 🛡️ Passo 6 · Il subagent test-guardian: chi controlla i test
- 🪝 Passo 7 · Lo Stop hook: il gate che blocca il fatto
- 🧪 Passo 8 · La prova del fuoco: rompo un test e provo a fermarmi
- 📦 GitHub repo
- ✅ Checklist finale: il metodo in otto mosse
🔍 Perché il TDD è il guinzaglio giusto per un agente
Un agente di coding è velocissimo a produrre codice plausibile; il collo di bottiglia è verificare che sia giusto. Il test-driven development sposta la verifica prima della scrittura: il test è la specifica eseguibile, e l'agente ha un bersaglio oggettivo invece della mia descrizione a parole.
C'è anche un motivo più prosaico: i modelli tendono a dichiarare "fatto" troppo presto. Con una suite rossa sul tavolo, "fatto" smette di essere una frase e diventa uno stato del sistema: o i test passano, o non hai finito. È un criterio che posso far rispettare meccanicamente, come vedo tra poco con gli hook.
Il flusso che uso è il classico red → green → refactor, con una regola in più pensata per gli agenti: ogni fase produce un output verificabile (il fallimento del test in red, l'output verde in green) che pretendo di vedere nel transcript prima di passare alla fase successiva.
Ogni fase produce un output verificabile; hook e subagent chiudono le scappatoie.
⚠️ Come un agente bara sui test (e cosa devo impedire)
Prima di montare i guardrail devo sapere da cosa mi difendo. Quando un agente si trova un test rosso davanti e non riesce a farlo passare, le scorciatoie che ho visto più spesso sono tre: cancellare o skippare il test, indebolire le assert (un `Assert.Equal` che diventa `Assert.NotNull`), oppure aggiornare il valore atteso per farlo combaciare con l'output sbagliato dell'implementazione.
C'è poi una quarta scorciatoia più sottile: l'overfitting — codice che passa esattamente i casi testati con una catena di `if`, senza implementare la regola generale. Contro questa l'arma è scrivere più casi di quanti l'agente possa memorizzare convenientemente e fare review del diff.
La strategia complessiva: le regole scritte in `CLAUDE.md` dicono all'agente cosa non fare, lo Stop hook rende impossibile chiudere con la suite rossa, e il subagent guardiano controlla i diff dei test dopo ogni fase green.
- Test cancellati o skippati: il guardiano cerca `Skip =` e `[Fact]` commentati nel diff.
- Assert indebolite: `Equal` → `NotNull`/`True` è il segnale classico.
- Valori attesi riscritti: il test si adegua al bug invece del contrario.
- Overfitting: passa i casi del test, non la regola; si combatte con più casi e review.
🏗️ Il progetto del tutorial: PriceRules, una Minimal API di pricing
Per tenere il fuoco sul metodo uso un dominio piccolo ma reale: PriceRules, una Minimal API con un solo endpoint `POST /quote` che prezza un carrello. Le regole di business sono perfette per il TDD perché sono piene di casi limite: sconto volume del 10% sulle righe con almeno 10 pezzi, coupon percentuali applicati dopo lo sconto volume, coupon sconosciuti ignorati.
Lo stack è il mio solito: .NET 8, Minimal API senza Controllers, record tipizzati per i DTO, LINQ per i calcoli, xunit per i test unitari e `WebApplicationFactory` per i due test di integrazione sull'endpoint.
Il codice completo, insieme a hook e subagent già configurati, è nel repo di accompagnamento su GitHub: lo trovo linkato sotto ogni snippet e nel blocco dedicato a fine articolo.
POST /quote { items: [{sku, quantity, unitPrice}], coupon? }
R1 subtotale = somma di quantity * unitPrice
R2 righe con quantity >= 10 -> -10% su quella riga (volume-10pct)
R3 coupon noto (WELCOME10, BLACKFRIDAY20) -> % sul totale DOPO R2
R4 coupon sconosciuto -> ignorato, nessun errore
R5 carrello vuoto -> 400 Bad Request📁 Passo 1 · Creare soluzione, progetto e test project
Parto dallo scheletro: una soluzione con il progetto API e il progetto di test. Niente di speciale, ma lo faccio prima di aprire Claude Code: lo scaffolding non è lavoro da agente, e un progetto che già compila vuoto evita di sprecare i primi dieci minuti di sessione.
$ 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
# per i test di integrazione sull'endpoint
$ dotnet add tests/PriceRules.Tests package Microsoft.AspNetCore.Mvc.Testing
$ dotnet test # verde: 0 test, ma compiladotnet test · Microsoft Learn ↗
📜 Passo 2 · CLAUDE.md: le regole del loop, scritte per un agente
Il `CLAUDE.md` alla radice del repo è il contratto che Claude Code carica a ogni sessione. Qui non descrivo il progetto: descrivo il processo. Le tre regole chiave: mai codice di produzione senza un test rosso che lo richieda, mai modificare un test per farlo passare, e l'obbligo di incollare l'output di `dotnet test` a ogni fase.
La formulazione conta: "se un test ti sembra sbagliato, fermati e dillo" dà all'agente una via d'uscita legittima — senza quella, un modello messo all'angolo tende a inventarsi la scorciatoia da solo.
## 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 e memoria · Claude Docs ↗
🔴 Passo 3 · Red: il prompt che chiede solo il test
La fase red è la più delicata: devo impedire all'agente di scrivere subito l'implementazione, che è il suo istinto naturale. Il prompt è esplicito: solo il test, per una regola, e mi aspetto di vederlo fallire.
Per la regola R2 (sconto volume) il test che ne esce fissa i numeri senza ambiguità: 10 pezzi da 5 € più un pezzo da 100 € devono dare 150 di subtotale, 5 di sconto, 145 di totale. Se il test passa al primo colpo è un allarme, non una buona notizia: vuol dire che non sta testando niente di nuovo.
[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);
}🧾 Verificare il red: l'output del fallimento nel transcript
Prima di autorizzare la fase green pretendo la prova del rosso: l'output di `dotnet test` con il fallimento, incollato nel transcript. È un'abitudine che costa dieci secondi e chiude un intero genere di imbrogli: un test che non ho mai visto fallire non dimostra niente.
$ 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🟢 Passo 4 · Green: il minimo codice che fa passare il test
Ora sì: chiedo l'implementazione, con il vincolo del minimo indispensabile. Il `PricingService` che ne esce è LINQ puro: somma del subtotale, sconto volume con `Where` + `Sum`, coupon cercato in un dizionario case-insensitive e applicato sul netto dopo lo sconto volume.
Nota lo stile: niente cicli `for`, `if` a statement singolo senza graffe, early return impliciti nella struttura. Sono le regole di stile che tengo nel mio `base-rules` e che l'agente rispetta perché sono scritte nel contesto, non perché le ricorda.
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);
}🧼 Passo 5 · Refactor a suite verde (e l'endpoint in Minimal API)
Con la suite verde posso rifattorizzare senza paura: estrarre le costanti `VolumeThreshold` e `VolumeRate`, sistemare i nomi. La regola è una sola: dopo ogni ritocco, `dotnet test`. Il refactor con la rete di protezione è il dividendo che il TDD paga per tutto il resto della vita del progetto.
L'endpoint è il pezzo meno interessante — ed è giusto così: la logica sta nel servizio testato a livello unitario, l'endpoint fa solo binding e validazione del carrello vuoto (regola R5), coperto dai due test di integrazione con `WebApplicationFactory`.
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));
});
}🛡️ Passo 6 · Il subagent test-guardian: chi controlla i test
Il primo guardrail è un subagent dedicato: `test-guardian`, un file markdown in `.claude/agents/` con tool in sola lettura. Dopo ogni fase green lo mando a leggere il diff e a cercare le tre manomissioni classiche: test cancellati o skippati, assert indebolite, valori attesi riscritti.
Il punto di forza è il contesto pulito: il guardiano non ha visto la fatica dell'agente principale per far passare il test, quindi non ha il suo bias — legge solo il diff e giudica. È la stessa logica della code review umana, a costo di un comando.
---
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.🪝 Passo 7 · Lo Stop hook: il gate che blocca il fatto
Il secondo guardrail è quello deterministico. Gli hook di Claude Code sono comandi shell agganciati agli eventi del ciclo di vita; l'evento Stop scatta ogni volta che l'agente prova a chiudere il turno. Se lo script esce con exit code 2, il turno non si chiude: lo stderr torna all'agente come istruzione, e l'agente riprende a lavorare.
Il mio `tdd-gate.sh` fa una cosa sola: lancia `dotnet test` e, se la suite è rossa, esce con 2 riportando le ultime venti righe dell'output. Risultato: "fatto" con i test rossi non esiste più, per definizione, qualunque cosa il modello creda.
#!/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 # blocca lo Stop, lo stderr torna all'agente
fi
exit 0
# .claude/settings.json
# { "hooks": { "Stop": [ { "hooks": [ { "type": "command",
# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/tdd-gate.sh" } ] } ] } }🧪 Passo 8 · La prova del fuoco: rompo un test e provo a fermarmi
Per fidarmi di un gate devo vederlo scattare. Il collaudo: cambio a mano un valore in `PricingService` (lo sconto volume dal 10% al 15%), poi chiedo all'agente di chiudere. Il gate lancia la suite, trova il rosso e rimbalza lo Stop: l'agente riceve l'output del fallimento e riparte a correggere — senza che io tocchi la tastiera.
È la differenza tra chiedere disciplina e imporla: il CLAUDE.md può essere ignorato in un momento di creatività del modello, l'exit code 2 no.
Il turno si chiude solo quando dotnet test passa: il gate collaudato dal vivo.
📦 GitHub repo
Tutto il codice di questo articolo è pubblicato in un repository pubblico: la soluzione PriceRules completa (Minimal API + 8 test xunit), il `CLAUDE.md` con le regole TDD, lo Stop hook `tdd-gate.sh` già registrato in `.claude/settings.json` e il subagent `test-guardian`. Clono, `dotnet test`, e il progetto è verde; apro Claude Code nella cartella e il gate è già attivo.
Non servono servizi esterni: niente database, niente Docker — la soluzione gira in-process con il solo SDK .NET 8.
$ git clone https://github.com/fscamuzzi/tdd-claude-code-dotnet.git
$ cd tdd-claude-code-dotnet
$ dotnet test # Passed! - Failed: 0, Passed: 8
# l'API dal vivo
$ 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 ↗
✅ Checklist finale: il metodo in otto mosse
Ricapitolo il metodo, che vale identico con Codex o qualsiasi altro agente che supporti regole di progetto e hook: il TDD fornisce il criterio oggettivo di "fatto", gli strumenti dell'agente lo rendono non negoziabile.
Da qui si sale: il gate può diventare più severo (coverage minima, `dotnet format --verify-no-changes`), il guardiano può girare anche su SubagentStop, e le stesse regole possono entrare in CI come seconda linea di difesa.
- 01Scaffolding a manosln + api + xunit, compila vuoto
- 02CLAUDE.mdil processo, non il progetto
- 03Prompt redsolo UN test, per UNA regola
- 04Prova del rossooutput del fallimento nel transcript
- 05Prompt greenil minimo che fa passare
- 06Refactora suite verde, test dopo ogni ritocco
- 07test-guardiansubagent sul diff dei test
- 08Stop hookexit 2 se la suite è rossa
Il criterio di 'fatto' è dotnet test: scritto nelle regole, imposto dal gate.
Domande frequenti su TDD con Claude Code
Perché fare TDD con un agente AI invece di fargli scrivere i test dopo?
Un test scritto dopo tende a fotografare quello che il codice fa, non quello che deve fare — e un agente che scrive codice e test insieme può sbagliarli entrambi in modo coerente. Il test scritto prima è una specifica indipendente: definisce il bersaglio e rende il "fatto" verificabile con un comando.
Come impedisco a Claude Code di modificare i test per farli passare?
Su tre livelli: la regola esplicita in CLAUDE.md (con la via d'uscita "se un test ti sembra sbagliato, fermati e dillo"), il subagent test-guardian che controlla i diff dei test dopo ogni fase green, e la review umana del diff. L'esperienza dice che serve tutta la scala: la sola regola scritta non basta.
Cosa fa esattamente uno Stop hook con exit code 2?
L'evento Stop scatta quando l'agente prova a chiudere il turno. Se il comando agganciato esce con codice 2, la chiusura viene bloccata e lo stderr dello script torna all'agente come istruzione: nel mio caso l'output di dotnet test rosso, con l'ordine di correggere senza indebolire i test.
Il gate rallenta la sessione? dotnet test gira a ogni Stop.
Su questo progetto la suite gira in meno di due secondi, quindi no. Su suite grandi conviene fare girare nel gate solo i test rapidi (per esempio con un filtro di categoria) e lasciare la suite completa alla CI: il gate deve restare veloce per restare accettabile.
Questo metodo funziona anche con Codex o altri agenti?
Il metodo sì: red-green-refactor con prova del rosso è indipendente dallo strumento, e le regole di progetto stanno in AGENTS.md invece che in CLAUDE.md. Cambiano i guardrail nativi: gli hook con exit code 2 e i subagent con contesto separato sono funzioni di Claude Code; con altri agenti la seconda linea di difesa resta la CI.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.


