Back

AI TDD: when AI writes the tests, you still get to code

Open water

Introduction

Unless you live under a rock, you probably know that AI is sweeping through every profession which rely on coding as a daily tool, offering new workflows and, for many, alienation from their own codebases. This transformation has been very divisive: many embraced AI as a natural evolution of abstraction over the machine which enables faster product engineering, while another large chunk is witnessing their passion and craft being reduced to what feels like babysitting a chatbot. Somewhere in the middle, you have those like me, who can see how incredible this technology is, feel the excitement even, but still long for the days where it was just you and your code.

Well, I'm an optimist, and I think we, and by we here I mean those who consider coding as a craft, a form of art and expression, or even a meditation-like activity, we can make it through this, turn this foe into an unexpected ally. After all, as I discussed before, which one among us cannot remember being 13 and dreaming of an artificially intelligent1 computer, understanding us and working autonomously. Now that it's there, it's time for us to make it work for us!

In this post, I want to share a method of using LLMs which I've been experimenting with for a bit and which I've truly enjoyed. My usual AI workflow for a new project is this:

  1. Brainstorm with the LLM about what I want to achieve, come up with an architecture.
  2. Start implementing it myself, scaffold my project, create basic features.
  3. Get tired, or impatient, and ask the LLM to implement just this tiny thing for me to unblock me.
  4. Get back to coding everything by hand. Loop back to step 3.

I know, I know, I said I'm part of those who care deeply about coding, why do I even let the LLM get near my code? The answer is simple: I'm human, and some days I'm just reaaally tired. Not every feature of a project is born equal, some are just tedious, and it's so easy to ask GPT-san to take care of it.

The problem with the workflow above is that writing some parts of the codebase by hand helps, but not enough. The main drawback of AI coding rears its ugly head anyway: by delegating portions of the code to AI, I really struggle to keep an up-to-date, clear mental picture of my code. So much so that, after a while, it just feels like someone else's code, and understanding what happens becomes really unpleasant.

This is where "AI TDD" comes in. The idea here is to essentially scrap parts 2–4 of my little workflow above (I still recommend brainstorming with AI, it doesn't cost much and can really help get a better idea of where you're going), and instead have the AI write tests for you. That's it.

Why not write the tests yourself?

Fair point, that's regular TDD. The dirty little secret here is I don't like TDD. Well, the regular kind. Firstly because I don't like writing tests, like, in general. And secondly because I find it a very frustrating way to think about code. I know it's supposed to be better, because you have to know what your function does before you implement it, but somehow it messes with how I think about my program and the way modules, functions, etc. connect to each other. Plus, did I mention that writing tests is really dull?

What I do like in TDD is when it's used pedagogically. Say you're following a book, a tutorial, or anything like that, and to help you implement something, all you get are tests. The tests are your whole documentation, your whole spec. All you have to do is write the implementation, till it's all green! Well I like that, it really helps me work and learn effectively. AI TDD is just a natural extension of this, where you get AI to do the annoying work and you get to enjoy the fun part.

This method is definitely more useful if your goal is to learn a new language, a new method, a new framework. But that doesn't mean you can't use it to implement any feature you like. In this latter case, you might just need to be more direct with the LLM as to what you see as implementation (in contrast to the pedagogical experience of having the LLM figure it out for you mostly).

Use case: Markava, a superset of CommonMark

To illustrate the principle of AI TDD, I thought I'd show you with a concrete use case. I wanted to get reacquainted with OCaml. It's been a decade since I last used it, and I've been on a functional programming kick recently, so I thought now would be a good time.

A cool thing to do with OCaml is write parsers and compilers. It's just really really good at that.2 I decided to choose a simple project which could also be useful to me: build a parser for a custom markup language. I went for a superset of CommonMark so that I can integrate it into enclume, my homegrown static site generator.

So this markup language, Markava, is essentially just CommonMark with custom HTML tag shorthands (see the README for more details).

Markava example
::::div.container

::a[href=https://nchagnet.eu] My website

::::

That's it: rather simple, but useful when you need to add lots of HTML components (often with complex attributes) without polluting your markdown with a gazillion tags.

How I used AI TDD to create it

The process itself is rather smooth and can be easily distilled into a simple skill:

  1. Ensure the LLM has or comes up with an implementation plan (so either provide your specs, your goal, or your own implementation plan), and have it break it down in atomic steps.
  2. Ask the LLM to write tests for each feature, function, and type required by the implementation plan. This is where I usually highlight the pedagogical nature of the exercise and request well-commented and documented tests to make it easier for myself.
  3. Implement the tests by groups (milestones, features, whatever) until they're all green.
  4. Profit?

You might need to refine the process for yourself, get your agent and especially the model to output tests at a granularity that fits your needs. For example, my first attempt with Markava led to end-to-end tests only, with little guidance in the way of how to implement a parser. Once I got that right, DeepSeek v4 Flash broke it down into "milestones", each with its own tests.

To start with, the agent generated the AST structure I will need based on the spec, so that's the first thing I reviewed.3 The first milestone was to create the parsing function for the inline part of the AST tree, with the associated tests.

lib/ast.ml
type attr =
  | Attr of string * string
  | Flag of string

type tag = {
  name : string;
  classes : string list;
  attrs : attr list;
}

type inline =
  | Text of string
  | Strong of inline list
  | Em of inline list
  | Code_span of string
  | Link of { href : string; title : string option; children : inline list }
  | Image of { alt : string; src : string; title : string option }
  | Tag of tag * inline list

type block =
  | Paragraph of inline list
  | Heading of int * inline list
  | Code_block of { language : string option; code : string }
  | Thematic_break
  | List of { ordered : bool; items : inline list list }
  | Blockquote of inline list
  | Tag_block of tag * block list

exception Syntax_error of int * string
lib/inline.mli
val parse : start_line:int -> string -> Ast.inline list
(** [parse ~start_line text] parses [text] as inline Markava.

    [start_line] is the 1-based source line where [text] begins. It is used to
    report [Ast.Syntax_error] lines when [text] spans several source lines.
    Adjacent [Ast.Text] nodes are coalesced. Raises [Ast.Syntax_error] on a
    malformed inline tag. *)
test/test_m1_inline.ml
(* Stage 1: implement Markava.Inline.parse in lib/inline.ml. *)
open Test_helpers

let test_empty = check_inlines "empty text" "" []

let test_text = check_inlines "plain text" "hello" [ text "hello" ]

let test_coalesce =
  check_inlines "text around emphasis stays coalesced" "a *b* c"
    [ text "a "; em [ text "b" ]; text " c" ]

let test_newline = check_inlines "newlines are preserved" "a\nb" [ text "a\nb" ]

let test_raw_text =
  check_inlines "text is stored unescaped" "1 < 2 & 3" [ text "1 < 2 & 3" ]

let test_strong_star =
  check_inlines "strong with **" "**bold**" [ strong [ text "bold" ] ]

let test_strong_underscore =
  check_inlines "strong with __" "__bold__" [ strong [ text "bold" ] ]

(* ... *)

Organized this way, it's pretty easy to build a new project even in a language I'm not super familiar with. It definitely feels easier than starting from scratch.

Conclusion

Working like this has been quite fun! You can go to the repo to take a look at the code if you want, I'm still working on implementing it all. I hope this can serve as inspiration on how to use AI for self-development without completely giving in to the agents.


  1. I wanted to write "super intelligent", but I guess this has been somewhat tainted to me now... ↩︎

  2. The Rust compiler was actually written in OCaml for a bit before being replaced by a Rust-based one. ↩︎

  3. The scaffolding required to write tests might be the weak point of this method. For example, I didn't write the AST types myself. Yet these parts are so closed to pure definitions, they're much easier to review than complex operations. ↩︎