BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

LLMs write the tests. Who tests the tests?

A year after building a CLI that generates unit tests with a language model, and after training sixty developers to work with these tools, the generation was never the hard part.

Last year I built Hermes, a small CLI that reads your staged changes and asks a language model to review them or write unit tests for them. Over the same two years I trained more than sixty developers, and these tools were part of how we worked. I started out thinking that generating the test was the hard problem. Both experiences point somewhere else: producing a test is now nearly free, and that has moved all of the difficulty to knowing whether the test is worth having.

What the tool does

The design is deliberately plain. It reads the files staged in git, puts each one into a prompt, sends it to the model, extracts the code from the reply and writes a test file next to the source. Prompts are text files in the repository, so a team can change them in a pull request like any other code.

That last decision is the one I would defend hardest. A prompt is part of the build. If it lives in someone's chat history, the team has no shared tool, only individual habits.

The failure mode: tests that agree with the code

Ask a model to write tests for a function and it reads the function, works out what it does, and asserts that it does that. If the function has a bug, the test now asserts the bug.

// the code under test truncates: 16.9575 should round to 16.96
expect(applyDiscount(19.95, 0.15)).toBe(16.95)
// the code under test truncates: 16.9575 should round to 16.96
expect(applyDiscount(19.95, 0.15)).toBe(16.95)

The test passes. Coverage goes up. The suite is green. And the defect has been promoted from an accident to a specification, with a test that will fail when somebody fixes it.

This is not a model quality problem that the next release removes. It follows from the input. The code says what the program does. It does not say what the program should do, and no amount of capability recovers information that was never provided.

Give it the intent, not the implementation

The generated tests improved most when we changed what went into the prompt, not which model received it.

  • Start from the contract. The function signature, the types, the doc comment and the ticket text describe intent. The body describes one attempt at it.

  • Ask for cases before code. A list of cases in plain language ("empty list", "discount above 100%", "currency with three decimals") is something a person can review in seconds. Test code is not.

  • Name the unknowns. When the model has to guess the expected value, it should say so in a comment instead of asserting its guess with confidence.

The second point is the one that changed how people worked. Reviewing a list of cases is a design conversation. Reviewing two hundred lines of generated assertions is proofreading, and nobody does it carefully, me included.

A test has to fail once

A generated test has never been red. That is true of any test written after the code, and it means there is no evidence that it can detect anything.

The check is old and cheap: break the code on purpose and run the test. Change a comparison, return early, remove a line. If the test still passes, it was decoration. Done by hand on the functions that matter, this takes minutes and it is the difference between a suite and a pile of files.

What sixty developers taught me

The training sessions were more instructive than the tool, and more humbling. Several of the things I taught in the first sessions I had stopped saying by the last ones.

The fastest adopters were not the strongest reviewers. Enthusiasm for generating code and care in reading it are separate skills, and the second one is what limits a team.

Volume hides quality. A pull request with four hundred lines of generated tests looks thorough. Reviewers skim it, and I did too. The teams that did well put a ceiling on generated test code per change and asked for fewer, sharper cases.

People wanted a rule about when not to use it. The one that held: do not generate tests for code you do not understand yet. The model will produce them happily, and you will have confirmed your misunderstanding in writing.

The code review standard mattered more than the prompts. One sentence did the most work: the author is responsible for every line, whoever typed it. It ends the conversation about whether the model made the mistake.

Where it is worth it

After a year, the uses that survived are narrower than the demos suggested.

  • Scaffolding: the imports, the setup, the fixtures, the shape of the file.

  • Enumerating cases I had not thought of, as a list, for me to accept or reject.

  • Characterisation tests around legacy code before changing it, where pinning current behaviour, bugs included, is exactly the goal.

What did not survive is the idea that started the project: point it at a codebase and receive a test suite. You receive a description of the code, written as assertions.

What I believe now

Writing tests was never the constraint on quality. Deciding what the software is supposed to do was, and still is. A model that writes the assertions faster makes that decision more visible, because it is now the only part left.

This article was proofread with the help of AI.