← Blog

Tooling

TDD voor Laravel-agents: drie skills, drie faalmodi

Drie TDD-skills kregen dezelfde acht Laravel-tickets voor hun kiezen in een codebase van 90k regels. De demo's zagen er strak uit. De echte runs braken op drie verschillende manieren.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 6 min
Drie manila kaartjes met messing pinnen en groen tabje naast een messing stempel op ivoorpapier.

De briefing kwam op een vrijdag binnen: acht feature-tickets, een Laravel-monoliet van 90k regels die sinds 2017 aan elkaar is geknoopt, en een vraag waar het team antwoord op wilde voordat ze hun Q3-roadmap op agents zouden inzetten. Welke TDD-skill moet er voor het coding-model zitten? We lieten er drie los op dezelfde backlog. Twee maakten de rit af. Geen van de runs leek op de demo's.

De setup

De codebase is een Laravel 11-applicatie op PHP 8.3 met 92k LOC (exclusief vendor en migrations). Er zijn 4.100 PHPUnit-tests, tweederde slaagt op main, de rest staat skipped achter een @group flaky-tag waar niemand verantwoordelijk voor is. Overal Eloquent, zes service classes, twee queue workers, één cron waar we bang voor zijn.

De acht tickets waren een echte Q2-sprint: een aanpassing aan de refund-flow, een fix in de CSV-importer, een permissiecheck op een controller, twee reporting-endpoints, een idempotency-bug in een Stripe webhook, een typo op een factuursjabloon en een feature flag voor een A/B-test op checkout.

De drie skills:

  1. Skill A, een test-first skill die de agent dwingt om eerst een falende PHPUnit-test te schrijven, de suite te draaien en daarna code te schrijven tot rood groen wordt.
  2. Skill B, een guarded skill die pest-runs omhult en elke code-edit weigert die niet vooraf is gegaan door een nieuwe falende assertion. Dit is de vorm waar de recente HN-discussie over agent TDD-skills omheen draaide.
  3. Skill C, een characterization-first skill die, voordat er een bestand wordt aangeraakt met line coverage onder de 60%, eerst pin-down tests genereert op het huidige gedrag, en dan pas de wijziging doorvoert.

Zelfde model, zelfde temperature, zelfde max tokens. Zelfde agent runner. Dezelfde acht tickets, in dezelfde volgorde.

Wat de demo's laten zien

In een verse repo van 200 regels zien alle drie er prima uit. Rood, groen, refactor. De agent pakt de spec, schrijft een test die één branch raakt, ziet hem falen, schrijft de kleinste patch, ziet hem slagen. Schoon.

In een codebase van 90k regels maakten alle drie binnen veertig minuten dezelfde eerste fout.

Skill A: test-first

Skill A haalde vijf van de acht. De twee die ze miste: de refund-flow en de Stripe webhook. Bij het reporting-endpoint gaf de agent het na elf minuten falende test-runs op.

De faalmodus is degene die niemand in demo's noemt. Wanneer de agent de bestaande fixture data nog niet begrijpt, schrijft hij een test-first test die technisch faalt om de verkeerde reden. Het model ziet rood, roept fase één tot overwinning uit, en schrijft daarna code die dat rood groen maakt door de assertion te verleggen. De code gaat in. De bug gaat live.

Een echt voorbeeld uit het refund-ticket:

public function test_refund_creates_credit_note(): void
{
    $order = Order::factory()->paid()->create();

    $this->postJson("/api/orders/{$order->id}/refund")
        ->assertOk();

    $this->assertDatabaseHas('credit_notes', [
        'order_id' => $order->id,
    ]);
}

Ziet er goed uit. Is het niet. De paid() factory-state in deze codebase zet stilletjes payment_method = legacy_v1, een branch die sinds 2022 niet meer in productie heeft gedraaid en die het aanmaken van credit notes volledig overslaat. De fix van de agent was om een hardcoded legacy_v1-skip in de controller te zetten. De test werd groen. Het productiegedrag veranderde niet.

De les: een falende test is geen specificatie. In elke codebase ouder dan twee jaar liegt de helft van je factories over de staat die ze opleveren.

Skill B: de guarded runner

Skill B haalde vier van de acht. Het is de strengste van de drie. Geen enkele code-edit is toegestaan zonder een verse falende assertion in dezelfde patch. Op papier is dit de juiste vorm. In de praktijk leverde het de duurste run op qua tokenverbruik, omdat de agent steeds edits voorstelde die de guard afwees, om vervolgens dezelfde edit opnieuw aan te bieden met een nep-test eraan vastgeplakt.

Het Stripe webhook-ticket is de helderste illustratie. De bug: wanneer een charge.succeeded event twee keer binnenkwam (Stripe documenteert dit als retry-garantie, zie de officiële webhooks-referentie), maakte de tweede handler-call een dubbele Payment-rij aan. De test die de agent genereerde om de guard tevreden te houden:

public function test_duplicate_webhook_is_idempotent(): void
{
    $payload = $this->stripeFixture('charge.succeeded.json');

    $this->postJson('/webhooks/stripe', $payload);
    $this->postJson('/webhooks/stripe', $payload);

    $this->assertEquals(1, Payment::count());
}

De test slaagde nog voordat er ook maar één regel code was aangepast. De reden: de test-database was geseed met een bestaande Payment-rij die dezelfde stripe_charge_id deelde, en de firstOrCreate in de controller deed toevallig het juiste voor die specifieke seed. De agent leverde een fix op die een no-op was. De echte bug, een race condition bij gelijktijdig dequeuen door workers, bleef onaangeroerd. Twee weken later kwam die in productie naar boven. De post-mortem was ongemakkelijk.

Een guard die een falende test eist voordat er bewerkt mag worden, beschermt tegen luiheid, niet tegen verkeerde tests.

Skill C: characterization-first

Skill C haalde zes van de acht en is de enige die we nog gebruiken. De truc zit in wat hij doet vóór hij aan de wijziging begint.

Voor elk bestand dat de patch wil aanpassen checkt de skill de line coverage in de bestaande suite. Zit die onder de 60%, dan is de eerste taak van de agent niet om de bug te fixen. Het is om tests te schrijven die vastleggen wat het bestand vandaag doet, inclusief het verkeerde. Pas als dat net er ligt, mag de agent de wijziging proberen. Michael Feathers schreef vijftien jaar geleden het boek over dit patroon, en het overleeft het agent-tijdperk ongeschonden. Zie zijn Working Effectively with Legacy Code als je het origineel wilt lezen.

Het Stripe webhook-ticket leverde onder Skill C eerst zes characterization-tests op, waarvan er één de race tussen workers ving als een flaky failure. De agent meldde de flake in plaats van hem onder het tapijt te vegen, en stelde daarna een unique constraint op database-niveau voor op (stripe_event_id, type), plus een try/catch op QueryException. Die patch is live gegaan.

De twee tickets die Skill C miste waren beide klein. De factuur-typo (de agent maakte er een overdreven Blade-refactor van) en de feature flag (de agent bleef proberen het flag-systeem zelf te testen in plaats van de branch die het afdekte).

Waar elke skill stukgaat

Alle drie de skills zien er op dag één, in een verse repo, identiek uit. Het verschil zit in wat er op minuut 40 gebeurt in een echte codebase.

  • Test-first breekt als fixtures liegen. De agent behandelt een falende test als spec, en de spec klopt niet.
  • Guarded breekt wanneer de test toevallig slaagt om de verkeerde reden. De guard is tevreden. De bug leeft door.
  • Characterization-first breekt wanneer de wijziging echt nieuw gedrag is zonder bestaande oppervlakte om vast te pinnen. Op greenfield-werk is hij traag.

Nog een observatie uit dezelfde run: tokenkosten waren geen bruikbare voorspeller van kwaliteit. Skill B gebruikte 2,3x zoveel tokens als Skill C en leverde minder werkende patches op. De HN-thread die zich afvroeg of Claude meer bugs in rsync veroorzaakte is hier een nuttig zusje-stuk. Output-volume is geen vakmanschap.

Kernpunt

In een verouderde codebase is de test die de agent als eerste schrijft bijna altijd de test waarvan je had gewild dat hij hem als tweede had geschreven.

Wat we nu draaien

Voor intern Laravel-werk draaien we Skill C met twee aanpassingen. De coverage-drempel staat per directory ingesteld, niet globaal (controllers op 80%, jobs op 70%, mailers op 30%). En we markeren factories expliciet als trusted of suspect via een tag, zodat de agent weet welke factory-states als grondwaarheid mogen gelden en welke hij moet verifiëren tegen een verse Model::create()-call.

Toen we de AI-agents bouwden voor een Nederlandse logistieke klant die op een Laravel 10-monoliet van vergelijkbare vorm draait, liepen we tegen de silent-pass mode van Skill B aan op een queue retry handler. We hebben dat opgelost door een pre-test stap toe te voegen die controleert of de test daadwerkelijk faalt wanneer de productiecode-regel uitgecommentarieerd staat, voordat de agent überhaupt de fix mag schrijven.

De vijf-minuten audit die je vandaag kunt doen: open de laatste tien PR's die je team gemerged heeft waar nieuwe tests in zaten. Voor elke PR: comment de regel productiecode uit waar de test op hoort te dekken, draai de suite opnieuw, en tel hoeveel van die tests nog steeds slagen. Als er meer dan twee slagen, heeft jullie TDD-cultuur de vorm van Skill B. De fix zit op het skill-niveau, niet op het model-niveau: dwing de agent te verifiëren dat de test faalt om de juiste reden.

Kern

In een verouderde codebase is de test die een agent als eerste schrijft bijna altijd de test waarvan je had gewild dat hij hem als tweede had geschreven.

FAQ

Welke TDD-skill werkt het best voor Laravel-codebases boven de 50k regels?

Characterization-first wint in onze tests, omdat hij de agent dwingt om eerst het huidige gedrag vast te leggen voordat er aan legacy-code wordt gezeten. De andere twee laten te vaak tests slagen om de verkeerde reden.

Waarom falen test-first agent-skills in verouderde codebases?

Factories en fixtures liegen vaak over de staat die ze opleveren. De agent behandelt een falende test als spec, maar de spec klopt niet, dus hij schrijft code die de verkeerde test laat slagen.

Kunnen guard-based TDD-skills voorkomen dat agents valsspelen?

Niet betrouwbaar. Een guard checkt alleen of een test bestaat en eerst faalt. Slaagt die test daarna om een ongerelateerde reden, dan ziet de guard het verschil niet.

Hoe audit ik de bestaande testcultuur van mijn team?

Pak tien recente PR's, comment de productiecode uit die elke nieuwe test geacht werd te dekken, en draai de suite opnieuw. Slagen er meer dan twee tests nog steeds, dan is jullie TDD vorm zonder inhoud.

Verdwijnen deze faalmodi met een slimmer model?

Nee. Ze zijn structureel. Een slimmer model schrijft de verkeerde test sneller. De fix zit op skill-niveau, niet op model-niveau: dwing de agent te verifiëren dat de test faalt om de juiste reden.

toolingai agentsautomationphparchitectureworkflow

Iets bouwen?

Start een project