Skip to main content
Antal István

Thoughts

Testing and static analysis

In mathematics nothing is real until you prove it. In code the proof is called tests.

I have made the case for tests and static analysis before, back when a human wrote every line. Now that an agent writes most of them, we need even more of both.

Unproved code is a conjecture

A test proves one case. A type proves a property over every input. Formal verification is real proof, and almost nobody ships it. So tests and types together are the closest thing to proof we actually practise, and code with neither is a conjecture: it might be true, nobody has checked.

That was always so. What changed is who produces the conjectures, and how fast. An agent generates code quicker than anyone can read it, and it announces "done, everything works" with the confidence of a theorem and the evidence of none. It has no memory of the last session and no shame in this one. It does not feel the dread of a Friday deploy. It will not remember that the last time it touched this module, billing broke. Every instinct a human developer leans on in place of proof, the agent lacks — so it needs the proof more than we ever did.

And review does not save you. Reading every line was a plausible gate when lines arrived at human speed. At agent speed, review as the only gate collapses; the tests and the type checker are the only feedback that scales with generation, and the only feedback the agent can act on by itself. Tests stop being an artefact you leave behind for the next developer and become the agent's primary sense organ. Take them away and it is flying blind, and it will not tell you.

When SQLite got to the point where every branch was tested in both directions, the stream of bug reports stopped, and a team of three maintains what is possibly the most widely used piece of software in the world — Richard Hipp tells that story in this talk. That is what guardrails buy: the ability to make thousands of small changes quickly and without fear. An agent is a fourth committer who makes thousands of small changes a day and forgets all of them overnight. Same maths, higher stakes.

The excuse is gone

The number one excuse for not writing tests — I have used it too — has always been the same:

We don't have the time.

Well, now we do. The same agent that writes the code writes the tests, and it does not get bored and it does not have a deadline.

Which is exactly the trap. Ask an agent for tests and it will produce them by the hundred, and most will be derived from the implementation they are supposed to check. A test written by reading the code and asserting whatever the code does is a circular proof: it assumes what it set out to show. Mocks that verify a call sequence. Snapshots nobody has looked at. Four hundred green tests, and when one goes red the instruction is "fix the test", and it obliges.

The real damage is not the wasted tests, it is what they do to us. Every green run we approve without reading trains us to accept the next one, and after a few hundred repetitions the green tick means "approve", not "verified". A signal that is always green carries no information, and a reviewer who has learned that stops looking — at the tests first, and soon at the code. Hollow tests are worse than no tests. With none, you at least still read the diff. With hollow ones, you have permission not to.

Read the tests, not the code

If the agent writes both, your attention has to go somewhere, and it should go to the tests. If they are honest and they pass, the implementation is mostly fine, and where it is not, that is a bug you will find soon enough. If they are dishonest, no amount of reading the implementation saves you, because you are checking the workings of an argument whose premises are wrong.

Review the proof. Skim the workings.

A good test goes red when the behaviour is wrong and stays green when only the implementation changes. It reads as a statement of intent rather than a transcript of the code. And it belongs to a suite small enough that you actually read it. A small suite you read beats a large one you do not; quantity is the thing the agent gives you for free, and the thing you have to refuse.

Are these tests real?

Coverage will not tell you. Coverage measures which lines ran, not whether anything was checked, and an agent will take a suite to 100% coverage in an afternoon without proving a thing. Mutation testing asks the question coverage cannot: it changes the code — flips a condition, deletes a line, swaps a constant — and checks that a test fails. If none does, the mutant survives, and a surviving mutant is a precise map of something the suite never checked.

Stryker does this for JavaScript and TypeScript, cargo-mutants for Rust, and there is an equivalent for most languages worth using. Do not chase the score. Read the survivors. Most are either a test the agent should have written or a line that does not need to exist, and both are worth knowing; the rest are mutants no test could tell apart from the original, which is why the score never reaches a hundred. This is how green stays expensive to earn — and green that is expensive is green you can trust.

Put the proof in the code

Static analysis is the fastest loop there is. A type error names the file and the line and arrives in seconds, and an agent responds to it far better than it responds to prose. Strict mode on, everywhere. It is the cheapest guardrail you will ever install and the one the agent trips over first, which is exactly where you want it to trip.

Then the one function I add to every TypeScript codebase I maintain:

function assert<T>(condition: T, msg = 'Assertion failed'): asserts condition {
    if (!condition) {
        throw new Error(msg);
    }
}

Five lines, and the return type is the point. asserts condition tells the compiler that everything after the call may assume the condition holds, so one line is a runtime check and a static narrowing at once. It states the invariant right where the agent reads it, in a form it cannot mistake for a comment. It fires during the agent's own test runs, so the agent trips over a violated invariant with a stack trace and a message, instead of quietly returning a plausible wrong answer that no test happened to look at. And it lives on the code side of the line: an agent that games the test suite does not get past it, and a mutant that breaks the invariant dies whether or not a test was aimed at it.

Agents hate guardrails, and they are resourceful. A failing assert gets deleted, or wrapped in a try/catch that swallows it. A type error gets an as any, a @ts-ignore, an eslint-disable, an #[allow]. Every one of these turns a wall back into a suggestion. Lint the escape hatches shut, so the escape itself fails the build, and treat a removed assert as a review-stopper: not a change to discuss, a change to revert.

The pyramid did not go away

The classic case for the test pyramid was cost: unit tests are cheap and fast, end-to-end tests are slow and brittle, so keep the base wide and the top narrow. With an agent in the loop that case gets stronger, twice over. A slow feedback loop hurts an agent as much as it hurts us — you want it to course-correct within seconds, not after a ten-minute run — and the failures at the top are also the hardest ones to diagnose.

Type error
names the line.
Unit failure
sits a few frames from its cause.
End-to-end failure
is a wrong number in a browser, twenty layers away from the change that broke it.

The agent has to reason backwards across every one of those layers, and agents are bad at that in a way specific to them: they do not hold the whole system in their head, and their cheapest hypothesis is always that the test is flaky. So the further up the pyramid a failure lands, the more likely the agent misdiagnoses it, retries it, widens a timeout, or declares the test bad and deletes it. The pyramid is not just the ordering of cost. It is the ordering of how much you can trust the agent's reaction to a red.

Asserts and types move failures down the pyramid: an invariant violation surfaces at the point of cause, as a local, obvious failure, instead of two minutes later as a distant, misdiagnosable one. And when an end-to-end test goes red, this is the rule I give the agent, verbatim:

When an end-to-end test fails, do not fix it at that level. Reproduce it lower first: write the unit test that fails for the same reason, fix that, and only then re-run the end-to-end test.

Otherwise it patches the symptom, or the test. My own loop, in order:

1 · Types
Strict mode, escape hatches linted shut.
2 · Unit tests
Fast, local, close to the cause.
3 · Mutation
Read the survivors, not the score.
4 · Fresh stack
A brand new containerised copy of everything, and the full end-to-end suite against it.

Every test, on that fresh stack, before the agent even thinks about asking me to review the work.

Related