Back to the journal

Conversational TDD: Making AI Mistakes Cheap

I expect the AI i work with to make mistakes. That expectation is part of how i develop software.

It can misunderstand a requirement, overlook an existing constraint or implement something that looks perfectly reasonable until you try the case that actually matters. Giving it a longer document doesn’t remove my responsibility to catch that.

So i organise the work around short conversations, small changes and tests we develop together before implementation. I review what those tests actually demand. Then we build, check and move on.

I call this Conversational Test-Driven Development. The name describes the working loop. The useful question is how much code we let depend on an assumption before we check it.

Would you onboard a developer like this?

Imagine hiring a developer, handing them three thick folders of requirements on their first morning and expecting a complete, correct system back when they’re done reading.

You’d probably have a conversation first. Explain what the business needs. Look at the existing code together. Work through an example. Give them something manageable to implement and review the result before piling more work on top.

That’s the part of working with a developer that i find useful when working with AI, too. Both can misunderstand what i mean. Both can produce code based on a perfectly coherent interpretation of the wrong requirement.

The analogy stops there. I’m not claiming a language model thinks or remembers like a person. I’m describing the collaboration i need: questions, concrete expectations and feedback while the work is still small enough to inspect.

If the model gets something wrong, i want to find out while we’re dealing with one behaviour. Finding out after it has built the next five features around that mistake is a much more annoying afternoon.

The conversation produces the tests

I start with the next useful piece of behaviour. We discuss what it should do, what it depends on and what would count as a failure. The AI can point out an ambiguity or suggest a case i missed. I can explain a business rule that isn’t obvious from the repository.

We develop the tests in that conversation. I don’t just request a test suite and approve it because the names sound sensible. Before implementation, i review the important cases and their expected outcomes against the requirement.

That’s a critical checkpoint. If the tests encode the wrong behaviour, a successful implementation will give me the wrong behaviour with a green tick next to it.

Take a small, hypothetical example: a user may download an invoice only if it belongs to their organisation.

Before touching the implementation, we need to settle what that means. Does knowing the invoice ID grant access? What happens if the user supplies another organisation’s ID? How should a denied request behave?

For this example, we’d want tests showing that:

  • A user can download an invoice belonging to their organisation.
  • A user cannot download another organisation’s invoice, even with its exact ID.
  • A request without an authenticated user cannot download the invoice.

We also need to agree on the application’s denial response and check that no invoice contents leak into it. An assertion that merely checks for some error would leave too much room for a broken implementation to pass.

Those decisions belong before the implementation. Otherwise it’s very easy to end up testing whatever the generated code happens to do.

Red, green, refactor, then the next piece

Once the expectations are clear and i’ve reviewed the tests, the implementation follows the ordinary TDD loop.

Red: Run the new test and check why it fails. A missing access check is the failure we’re trying to expose. A broken fixture or a database connection error tells us something else. If the test already passes, we inspect whether the behaviour already exists or whether the test is failing to exercise it.

Green: Implement the smallest change that satisfies the agreed behaviour. Run the test again and check the existing tests relevant to the change. This isn’t the point to redesign half the application because the agent noticed an opportunity.

Refactor: Clean up what needs cleaning up while keeping the tests green. Then review the diff and the actual result. Passing assertions don’t tell me whether we’ve introduced an unnecessary dependency or made the surrounding code harder to understand.

Only then do we take the next piece of work.

The loop is familiar. What matters in my workflow is that the dialogue continues through it. An unexpected failure can expose a missing requirement. A proposed implementation can reveal that we misunderstood the existing architecture. We resolve that before building further on it.

Green tests can agree with a mistake

The same AI can write a test and an implementation that share the same wrong assumption. Asking it to review itself doesn’t make those two independent sources of evidence.

In the invoice example, it might assume that every authenticated user is allowed to download every invoice. A successful download test and a login-required test would both pass. We’d still have missed the organisation boundary entirely.

That’s why my review starts with the expected behaviour. Does this test describe the rule we agreed on? Would it reject the obvious wrong implementation? What important case have we left out?

Tests give us repeatable checks for the expectations we’ve encoded. They don’t establish that those expectations are complete. Security, integration behaviour and whether the feature is useful still need judgment and the appropriate checks.

As the software grows, the test suite preserves those checked expectations. When we change something later, regression tests can catch a behaviour we’ve accidentally broken. They also give the next conversation something concrete to refer to.

Keep the cost of a wrong assumption small

A wrong assumption can spread. Put it into an early design decision, then build storage, API behaviour and user interface around it, and correcting it becomes work across several parts of the system.

Small iterations give me an earlier opportunity to notice. If we disagree about what one test should assert, we can settle it before there’s an implementation depending on the answer.

That’s the economic argument for this approach. I’m trying to reduce the amount of work that has to be undone when we discover we’re wrong. I’m not attaching a productivity percentage to it. The practical target is a short distance between making an assumption and checking it.

Documentation still has a job. Architecture decisions, external contracts and business constraints need to be written down. A conversation also needs to leave behind the decisions that matter, rather than requiring somebody to reconstruct them from a chat history.

But i don’t expect an extensive specification to eliminate the need for feedback. Once implementation exposes a bad assumption, we have to revisit it, however carefully the original document was written.

The expectation changes the work

If i expect a flawless first attempt, every mistake becomes a surprise. The temptation is to add another instruction and hope the next attempt finally behaves like a compiler.

When i expect mistakes, i have an engineering problem i can work on: where will we detect this class of error, what evidence will we use, and how much work will depend on it by then?

My responsibility stays with me. I decide what we are building, check the important expectations and review whether the result is good enough to keep. The AI helps me reason through the problem and implement the next step.

I don’t need it to be right on the first attempt. I need a process that helps us find out when it isn’t, before the mistake gets expensive.

Try it on the next small feature: work through the expected behaviour with the agent, review the tests before allowing implementation, and watch where you first disagree. That’s a useful thing to discover while there’s still very little code to throw away.