After a year on the road, it's time to get back to the keyboard. But what's the best way to do it these days? That's what I need to figure out before my next job interviews. I'm starting with a talk by Matteo Vaccari on TDD in the age of AI. My questions: Can I trust tests that an agent writes itself? What's the point of defining test cases as specifications, and when in the development process should I interact with the agent? I had a small example built for me, injected some bugs, and wrote down what I learned. Enjoy!
After two years away from Germany, I'm finally back home. If you've followed my sporadic updates, you know we first lived in Canada and then set off on a year-long trip around the world. It feels crazy to be back in Germany after all this time. It isn't even that cold yet, and still I'm freezing as if it were the middle of winter. After the endless summer we had on our trip, the trees are now finally turning yellow and orange. Our journey took us through Central and South America to Southeast and East Asia. We experienced so much that I still need time to process it all, but there's no chance to catch our breath just yet.
My savings have shrunk considerably over the past year, so I need a new source of income. That's why it came at just the right time when two recruiters reached out to me at the start of the week. I'm happy about it, of course, but now I have a lot of catching up to do so I don't embarrass myself in interviews. I can count on one hand how often I found time to write code last year. My former colleague Chris from my time at DNAstack told me he now lets agents do all his programming. How does that work, and can you get clean code out of it? Time to find out!
The Talk: Green or Not Green, Is the Feature Done or Is the Agent Fibbing?
I'm starting with a talk by Matteo Vaccari: A Harness for Behaviour: Ensuring Ai-generated Code Does What We Want - TDD in the Age of AI. In it, Matteo describes his approach to developing with agents. In short: when programming with agentic AI, we can't trust the agents. We also can't trust the tests an agent writes, keyword reward hacking. But there's also a weakness at the very beginning of development, in the first iteration of a test. Birgitta Böckeler has looked into TDD in the agent loop (article). She points out: if the agent writes both the test and the code, a red test only shows that the agent ran it and saw it fail. It doesn't tell you whether it failed for the right reason. So what's the solution?
Reward hacking: AI agents are trained to reach a measurable goal, such as a passing test. The organization METR has observed that current models sometimes cheat to get there: they modify the tests or the scoring code, copy an existing solution, or exploit loopholes in the task instead of solving the actual problem (METR, 2025). In everyday work, it looks like this: instead of implementing the production code, the agent tweaks the assertions until the test passes. According to METR, the models often even know they are acting against the user's intent. A recent overview by MIRI concludes that more capable models cheat in more sophisticated ways and increasingly cheat on tests (MIRI, 2026).
The solution Matteo Vaccari proposes: the acceptance criteria are written as customer tests. The cases are stored as specifications in files, in his example as YAML tables, and a small test runner executes them. The system stays a black box. The agent is not allowed to rewrite these files to make the tests pass. It has to change the code. This is close to Ivett Ördög's "Approved Scenarios" approach, which Böckeler names as a good building block for trust in tests. She describes it as semi-manual testing with a dedicated runner per application: you review scenarios thoroughly and freeze them, and every later violation has to be approved again.
My aha moment: I had understood Matteo to mean that an agent uses the specifications to generate the tests and the code. Instead, the YAML files contain the test cases, and the test classes run them like a parameterized test. Smart, because that makes it obvious right away when an agent tries to cheat by changing a YAML file.
Learning by Doing
Okay, enough theory. What could this approach look like in a real project? To understand it, I first discussed what I'd learned from the video with Claude (Opus). That conversation produced a prompt, which I then used to have an agent build an example for me locally. It was meant to show me what the YAML cases, the reader, and the runner actually look like.
Spoiler: Here's the prompt so you can try it yourself
Build me a small, complete Java example project that shows how to organize tests as YAML tables ("customer tests") instead of hand-written test code, and when Mockito still makes sense. The project is for learning, so it should be small and easy to read.
Tech
- Java 21, Spring Boot 3, Maven, JUnit 5
- Read YAML with Jackson (jackson-dataformat-yaml)
- Mockito (mockito-core, via spring-boot-starter-test)
- Everything in memory, no database
Domain
Display prices in a foreign currency.
- PriceController: GET /price?item=apple¤cy=USD
- PriceService: fetches the exchange rate via the ExchangeRateClient
interface, uses PriceConverter, returns the result.
- If the rate is unavailable: business error RATE_UNAVAILABLE AND exactly one alert is sent via AlertSender.
- Unknown currency: UNKNOWN_CURRENCY (no alert).
- Unknown item: UNKNOWN_ITEM (no alert).
- PriceConverter: pure function, applies the rate and rounds to 2 decimal places (HALF_UP). BigDecimal. Negative amounts and 0 are valid inputs.
- ExchangeRateClient: interface. HttpExchangeRateClient is the real implementation (HTTP call, configurable URL).
- AlertSender: interface to the outside world (e.g. an email to operations). LoggingAlertSender is the simple implementation that only writes to the log.
- Small fixed product catalog in code (apple = 1.00 EUR, pear = 2.50 EUR).
- Base currency is EUR.
Directory structure (create exactly like this)
src/main/java/com/example/shop/ ShopApplication.java, PriceController.java, PriceService.java, ExchangeRateClient.java, HttpExchangeRateClient.java, AlertSender.java, LoggingAlertSender.java, PriceConverter.java
src/test/java/com/example/shop/ support/YamlCaseLoader.java acceptance/AcceptanceCasesTest.java service/ServiceCasesTest.java service/FakeExchangeRateClient.java service/RateAlertTest.java (Mockito, see below) converter/PriceConverterCasesTest.java
src/test/resources/cases/ acceptance/.yaml, service/.yaml, converter/*.yaml
Also create a CLAUDE.md in the project root with the test rules from the "Rules" section below.
Testing principles
- The YAML files ARE the tests. No test code is generated from them. Each layer has exactly one runner (@TestFactory) that reads all YAML files in its folder and creates one DynamicTest per case.
- Black box: only input and visible result. No checking who calls whom, except in RateAlertTest (see below).
- Data instead of code: a new case is a new YAML entry, not new Java code.
- Don't mock your own classes. PriceService runs in the tests with the REAL PriceConverter. Only things that reach the outside world get replaced.
- Each layer has its own mini-language in domain terms:
- converter: table with amount, rate, expect. Directly against PriceConverter. Many cases: rounding (e.g. 1.005), 0, negative, large amounts.
- service: given (exchange rate or rate_service: unavailable), when (price with item and currency), then (price or error). The runner builds PriceService with a FakeExchangeRateClient fed from given, and an AlertSender that does nothing. Cases: normal case, rate unavailable, unknown currency, unknown item.
- acceptance: given rates, when get_price, then status and price. The runner goes through the real controller (@SpringBootTest with MockMvc). The external endpoint is replaced by a small stateful simulator (embedded JDK HttpServer that serves rates from given). Only a few cases, only wiring and HTTP status codes. Rounding is NOT tested again here.
- Don't test anything twice: rounding only in converter, business rules only in service, wiring only in acceptance.
- When a case fails, the runner prints the case name and the file name.
Mockito: when yes, when no
There is exactly one test that uses Mockito with verify: RateAlertTest (a regular JUnit 5 test, no YAML file). It should show where this makes sense, because the call itself is the behavior that operations sees.
- ExchangeRateClient is STUBBED with Mockito (when(...).thenReturn(...) or thenThrow(...)), AlertSender is mocked.
- Case 1: rate unavailable -> RATE_UNAVAILABLE AND verify(alertSender, times(1)).send(...)
- Case 2: rate available -> verifyNoInteractions(alertSender)
- Case 3: unknown currency -> verifyNoInteractions(alertSender)
- The real PriceConverter is NOT mocked.
- Add a short comment above the test class explaining why verify is allowed here: the alert is a visible effect on the outside world, not an internal call.
- In a second section of the README, show a DELIBERATELY BAD counterexample (only as a text code block, NOT as a runnable test): a test that mocks PriceConverter and only checks verify(converter).convert(...), and explain in 3 sentences why it proves nothing and breaks during refactoring.
Rules for the CLAUDE.md
- Tests are YAML files in src/test/resources/cases/. One runner per layer.
- Only change files in src/test/resources/cases/ when I explicitly say so. If a case would need to change, ask me first.
- No mocks for your own classes. Mockito only for stubbing return values at the boundary to the outside world (HTTP, database, email, payment).
- verify() only when the call itself is the business behavior (e.g. "exactly one alert goes out"), and only when I explicitly allow it.
- No commits with red tests. Don't skip or delete tests.
Process
- First show me the planned mini-language for each layer with one example case each, plus the plan for RateAlertTest. Wait for my okay before building everything.
- Then build everything completely, including all classes with logic and all cases (about 8 to 10 converter, 4 to 5 service, 2 to 3 acceptance, 3 in RateAlertTest).
- Run
mvn testand make sure everything is green. - Fault injection demo: one after another, introduce four plausible bugs into the production code: wrong rounding mode, swapped currency direction, wrong error code, and the alert being sent twice on RATE_UNAVAILABLE. For each, show which test catches it, then revert it.
- Refactoring demo: move the conversion out of PriceService into a new class (PriceCalculator) without changing behavior. Show that all YAML cases and RateAlertTest stay green, and explain why that wouldn't have been the case with a mocked converter. Then revert it.
- Write a short README.md covering the structure, the principle, and the section on the bad counterexample.
The result of the prompt was a solid Java repository. The agent stored the requested cases as YAML files in the project, and a helper class reads them into the tests. It reminds me a lot of behavior-driven development with Cucumber.
Spoiler: Example YAML file
# Normal cases: price from the catalog (EUR) in the requested currency.
cases:
- name: Pear in USD
given:
rates: { USD: "1.10" }
when:
price: { item: pear, currency: USD }
then:
price: "2.75"
- name: Euro needs no rate, even when the rate service is down
given:
rate_service: unavailable
when:
price: { item: apple, currency: EUR }
then:
price: "1.00"
Spoiler: Example test class
class ServiceCasesTest {
@TestFactory
Stream<DynamicTest> serviceCases() {
return YamlCaseLoader.tests("service", c -> {
PriceService service = new PriceService(givenRates(c), new PriceConverter(), message -> { });
PriceService.Result result = service.price(c.text("/when/price/item"), c.text("/when/price/currency"));
if (c.has("/then/price")) {
assertNull(result.error(), "unexpected error");
assertEquals(c.text("/then/price"), result.price().toPlainString());
} else {
assertEquals(c.text("/then/error"), String.valueOf(result.error()));
}
});
}
private static FakeExchangeRateClient givenRates(Case c) {
FakeExchangeRateClient rates = new FakeExchangeRateClient();
if (c.has("/given/rate_service") && c.text("/given/rate_service").equals("unavailable")) {
rates.makeUnavailable();
}
c.data().at("/given/rates").properties()
.forEach(e -> rates.setRate(e.getKey(), new BigDecimal(e.getValue().asText())));
return rates;
}
}
Then I tried it out and introduced two bugs into the code myself: a different rounding mode and a wrong return value in the PriceService. Both times a test failed. The refactoring also went through without problems, so the decoupling works. Of course, the code is very simple. Whether this holds up in day-to-day work remains to be seen. What are your experiences with it?
My Updated Workflow
I think this is how I'll implement features from now on:
- Describe the feature in plan mode.
- Design the test cases together with the AI and store them as YAML files.
- Let the agent implement the feature without changing the approved cases.
- Review the result.
The protection against agent trickery comes from the separation: the cases are data, the runner is code. A changed expected value stands out immediately in the diff. A loosened assertion in test code is much easier to miss. Matteo's main emphasis is "data instead of code".
A year ago, I already gained some experience with agentic development at DNAstack. Every test an agent wrote back then, and they loved writing lots of tests, I had to check. As my colleagues adopted agent-supported development more and more, their pull requests kept getting bigger. As a result, I spent more time reviewing PRs than working on my own tickets. To take pressure off reviewers and fight cognitive debt, meaning the growing gap between the existing code and how much of it the team actually understands, we started writing smaller pull requests. That helped us become productive again. So I was glad to see that Javier López Fernández, in his article "How to talk with AI?", also considers one big review at the end to be outdated. He wants small interactions with the AI: one single test assertion or a short interface definition per step, something you can check in seconds, combined with trunk-based development and continuous review. To me, that sounds less like handing programming off to an agent and more like working together, similar to pair programming. My workflow, on the other hand, lets the agent build an entire feature on its own. So far I prefer Javier's approach. From my experience, I don't trust agents to write high-quality code. We'll see whether the agentic loop or agentic pairing performs better. I have an open source project where I want to try this out soon. Stay tuned!
So What Have We Learned?
This deep dive into Matteo's talk and the articles by Birgitta Böckeler and Javier López Fernández made a few things clearer for me. I can't simply trust an agent or the tests it writes itself. I already knew that from my work at DNAstack. If the test cases are fixed as data up front, any change to them stands out in review, as long as I create the test files myself first. In my small example, the tests caught both injected bugs, and the refactoring didn't break anything either.
In my view, there's no best practice yet. My impression is that there are two camps: small steps with continuous review and trunk-based development (López Fernández) on one side, and specification up front with an agent loop (Vaccari, building on Ivett Ördög's Approved Scenarios) on the other.
Where I'm Still Stuck
The test runner is Java code, and in my example nothing protects it. Only the YAML files are locked by a rule in the CLAUDE.md. So an agent could still cheat in the test runner.
Here you can pick a camp. If you're in the optimistic camp, you'll use even more AI: a second agent checks whether the cases are really being read and executed. It would have a different goal, namely finding problems, while the first one is supposed to make the tests pass. Whether that works, I don't know. Both agents usually run on the same model, and a reviewer agent might take shortcuts too.
In the other camp, which is more critical of AI, I'd guess humans are needed more. Only a manual review can really make sure the tests test what they're supposed to. I suspect we won't get around that.
What's Next!
This is just the beginning of the series. Next, I want to understand what a harness actually is and what spec files are. After that, it's on to token economics: When do I use which model, how do I select it automatically for each task, how do I cut costs, and how do I track token usage over time? There's also the question of whether code should even be read anymore. And what is this new ATDD all about? As you can see, I still have a mountain of open questions ahead of me, but first I need to sort through the photos from our trip and swap my shorts for long pants.
While I was writing this post, I received the sad news that Patrick Stewart has passed away. To me, he will always be the best captain of the Enterprise. Rest in peace and Godspeed, Captain 🖖