Engineering StrategyMar 20, 20264 min read

AI in Software Testing: Useful for Everything Except the Hard Part

By Maplecode

Ask a language model to write tests for a function and it will produce a plausible suite in seconds. Run it, and most of the cases pass. It looks like the testing problem just got cheaper by an order of magnitude.

It got cheaper in the places that were already mechanical. The expensive part of testing was never typing the assertions.

The oracle problem does not move

A test needs to know what the right answer is. That knowledge comes from a specification, a regulation, a domain expert, or a decision somebody made three years ago and wrote down nowhere.

A model reading your code cannot recover it. What it can do is read the implementation and assert that the implementation does what it does. That produces a suite which is green on day one and completely silent about whether the behaviour is correct.

We have seen this go wrong in a specific and expensive way: a rounding rule in a billing path was subtly wrong, generated tests locked in the wrong behaviour, and the suite then blocked the fix. The tests were doing exactly what they were built to do. They just had no independent notion of correct.

The rule that follows: generated tests are safe for characterising existing behaviour before a refactor, and dangerous as a correctness claim on code that has never been verified. Those are different jobs and it is worth being explicit about which one you are doing.

Where the wins are real

Four areas repay the effort immediately:

  • Test data generation. Producing realistic, varied, schema-valid fixtures — including the awkward ones humans skip. This is genuinely tedious work that models do well.
  • Flaky test triage. Clustering failures across many runs to separate real regressions from timing noise. Pattern-matching over volume is exactly the right shape of problem.
  • Coverage gap analysis. Reading a diff and naming the branches nobody exercised. It will not tell you whether those branches matter, but it will tell you they exist.
  • Characterisation before a rewrite. Pinning down what a legacy component currently does, so you can change it and see what moved. Here the absence of an independent oracle is the point.

The economics are less flattering than the demo

Generating a thousand tests is easy. Maintaining a thousand tests is the actual cost, and it is charged every sprint.

A suite that grows faster than the team's ability to reason about it becomes a tax: builds slow, failures get muted, and eventually somebody adds a retry to CI and the signal is gone. We would rather ship two hundred tests a team understands than two thousand nobody reads.

Apply the same review bar to a generated test as to generated production code. If it would not pass review written by a person, it does not pass because a model wrote it.

The failure mode: coverage as a target

Coverage is a useful diagnostic and a terrible goal. Point a generator at a coverage number and you will hit the number, because executing a line is trivially easy and asserting something meaningful about it is not.

The result is a suite with excellent coverage statistics that catches almost nothing, and a team that now believes it is well tested. That belief is worse than the original gap, because it stops the conversation.

When we would advise against it

Do not put generated tests in the path of a regulated approval — anything an auditor will read as evidence that a control works. The provenance question ("who determined this was the correct expected value?") does not have a good answer, and you do not want to be discovering that during an audit.

Also skip it where the test is the specification. If the suite is how the team agrees on intended behaviour, generating it from the implementation inverts the relationship and quietly removes the design conversation the tests existed to force.

How we introduce it

Start where being wrong is cheap and detectable: fixtures, triage, coverage reporting. Keep a human as the author of every assertion that encodes a business rule. Track the suite's ability to catch real defects — the honest measure is whether it fails when you deliberately break something, not how many lines it touches.

Then set an upper bound on suite runtime and enforce it. A limit forces the question of which tests are earning their place, and that question is the one that keeps a suite useful over years.

A test with no author

Ordinary tests carry provenance. Someone wrote the case, and when it fails years later there is a person or at least a ticket explaining what it was protecting.

Generated tests arrive without that. When one fails during an unrelated refactor, the team faces a case nobody chose, asserting something nobody decided mattered. The path of least resistance is to delete it, which is sometimes right and sometimes removes the only check on a rule that took an incident to learn.

The cheap fix is to record intent at generation time. A one-line comment naming what the case protects, and the diff or ticket that prompted it, is enough to make the later decision possible. It costs nothing while the context is fresh and is unrecoverable once it is gone.

This matters more as generated tests age into a codebase. The suite becomes an artefact the team inherited rather than one it wrote, and inherited suites get muted rather than maintained.

Our quality engineering practice runs this alongside delivery automation, since a faster suite and a trusted suite are the same problem.

Start here

Let's build what's next.

Tell us where you are and where you want to be. We'll bring the engineering, the AI, and the governance to get you there.