blog-post

Photo by Clean Code Guy

Test Driven Development (now with AI)

Test-Driven Vibe Coding: Letting the Contract Lead the AI

When I started writing software professionally, one of the most compelling practices I encountered was pair programming combined with test-driven development (TDD). I no longer follow it as strictly as I once did, but its central idea has stayed with me and continues to shape how I approach development.

For those unfamiliar with TDD, the practice begins by writing a test that you know will fail because the implementation does not exist yet. You then write just enough code to make it pass, refactor as needed, and repeat.

What I like most is that you can write each test as though the software already works exactly the way you want it to. In effect, you ask: What should this look like when the work is done? The tests define the shape of what you are building, and the growing test suite becomes a shared source of truth—not only for whether the software works, but for what it is meant to do.

When combined with pair programming, one person can focus on defining the behavior through tests while the other focuses on implementing it. For the usual reasons—deadlines, limited resources, and competing priorities—I have not had many opportunities to work this way with a dedicated partner in recent years.

Recently, though, I realised that I lived in an age where my pair partner was always available to collaborate with.

The AI as your pair

Pair programming with an AI is genuinely powerful, but not for the reason people usually reach for (“it writes code fast”). It’s powerful because it collapses the loop between describing what you want and seeing it exist. I often discuss the concept of shortening feedback loops in development; minimising the amount of time between attempting something and finding out if that attempt was successful.

The trap, though, is the “vibe coding” failure mode: you describe something fuzzy, the AI produces something plausible, and you end up reviewing a large blob of code whose shape you never actually decided. You’re no longer the architect — you’re a reviewer of someone else’s guesses.

Driving the AI with a TDD didn’t feel like that. It gave me a way to define the exact contract the agent had to satisfy, before it could go off the rails and over engineer a solution.

Driving with tests

The pattern I settled into was a red/green/refactor straight out of the Agile playbook. I write the tests, the AI is responsible for making them green.

I’d hand over a failing test that described the next slice of behaviour, with an instruction like:

I’ve made changes to tests that describe my desired behaviour. We are doing red/green/refactor TDD. You are in charge of making tests green. After they are green, commit and return control to me.

Then I’d just… keep extending the test. Each round, the prompt was often as short as:

go again

That’s it. The test is the specification. The AI reads the diff, implements the missing behaviour, runs the suite, lints, type-checks, commits, and hands control back.

An example

The project I was working on is called popdelta — a small library for treating population evolution (ageing, births, deaths, migration) like a git repository. Each step in the evolution is just a commit against a set of tables. I had decided I wanted an on-disk store with a current_state/ folder and ordered deltas/.

I didn’t describe the API in prose. I described it as an assertion:

store = PopulationStore.create(population_dir, make_population())
assert (population_dir / "current_state").exists()
assert (population_dir / "deltas").exists()
assert (population_dir / "deltas" / "1_initial_commit").exists()

Green. Commit. go again.

Next I pushed on the editing workflow — and crucially, I used the test to pin down semantics I cared about as an architect, like immutability of the input and the fact that a commit returns the new state:

store.add_to_current_delta(
    table="persons",
    rows={"add": pd.DataFrame({"person_id": [6], ...})},
)

# Before committing, the original population should remain unchanged.
assert population.persons.shape[0] == 5

new_pop = store.commit_current_delta()
assert new_pop.persons.shape[0] == 6
assert Population.from_dir(population_dir / "current_state") == new_pop

Green. Commit. go again.

Then the subtle one — operation ordering. If you delete a row and replace a column in the wrong order, the column has the wrong number of values. I encoded that as the contract itself:

store.create_delta("conflicting rows cols")
with pytest.raises(ValueError):
    # Ordering matters and we can't establish it with keyword args:
    # if the row delete happens first, we can't supply 6 age values
    # when only 5 rows remain.
    store.add_to_current_delta(
        table="persons",
        columns={"replace": current_age + 3},
        rows={"delete": pd.DataFrame({"person_id": [6]})},
    )

# Sequenced correctly, it works:
store.add_to_current_delta(table="persons", columns={"replace": current_age + 3})
store.add_to_current_delta(table="persons", rows={"delete": pd.DataFrame({"person_id": [6]})})

I never explained how to enforce that. I asserted the behaviour I wanted and let the AI find the implementation that satisfied it.

Why this works for an experienced architect

Decades of experience don’t make me faster at remembering whether it’s DataFrame.merge(..., indicator=True) or the exact pandas incantation to drop a column. That knowledge is real but low-value, and fighting syntax is a tax on my actual job.

What is high-value is deciding the shape of the thing:

  • Should state be materialised or reconstructed from deltas? (Materialised — don’t hide the current state like git does.)
  • What are the legal operations? (add/delete rows, add/delete/replace columns — five primitives that cover everything.)
  • What invariants must hold? (Inputs are immutable; ordering is explicit; a replacement column must match the row count at that point in the sequence.)

Writing tests first allowed me to make those decisions, and only those decisions. Then those design decisions become part of the implementation contract that the agent has to execute. The agent can then handle the pandas, the Parquet I/O, the manifest serialisation — and when ruff complains that a function is too complex, it refactors without me asking.

I get more control over the architecture because I’ve stopped typing the implementation.

The rhythm

By the end, the loop looked like this:

  1. I write (or extend) a test describing the next behaviour.
  2. go again.
  3. The AI implements, runs tests + lint + types, commits on green, hands back.
  4. Repeat.

It’s the pair-programming-plus-TDD experience that captivated me early in my career — except my pair never tires, never skips the test run, and never argues about where to put the braces (a hill I am always willing to die on). My entire job becomes authoring the contract and judging whether it’s the right one.

That’s a very good place for a software architect to spend their time.

Related Articles