· Software Engineering

The Tests Pass, Now Read the Code

Recently I added support for a new TypeScript version to a small tool I maintain (react-router-lint-routes). I wanted it to keep working with the old version too, so the tool needed an interface to hide which compiler a project uses. I opened a planning session with Claude Code and we iterated on the approach and how to verify the result.

After some time, the agent was done. The code compiled. The type checker and the linter were clean. Every test passed. I read the code and the first interface it wrote had a flaw that none of those checks could flag.

Here is what it wrote, with some of the names simplified:

interface CompilerAdapter {
  sourceFilesFromTsconfig(projectRoot: string, tsconfig: string): SourceFile[];
  sourceFilesFromMemory(files: Record<string, string>): SourceFile[];
}

The tool loads a project's source code and reports problems in it. In production it always loads the source from a tsconfig.json file on disk. It never loads from memory. The second method is there for one reason. The tests need a way to hand the adapter source code as plain strings, so they do not have to write real files to disk.

So one of the two methods on this production contract exists only for the tests.

Test-induced design damage

This has a name: test-induced design damage. It means production design that gets worse because someone wanted the code to be easier to test. That is what happened here. Nothing in the tool needs an in-memory adapter. The tests need it and the quickest way to give the tests what they wanted was to add a method to the interface they were already testing.

Editing an interface is not a small edit, it has a wider impact. An interface is a contract that every implementation has to satisfy and every future reader has to account for. Whatever you put on it, you pay for again and again.

What the extra method costs

Two adapters sat behind this interface, one for each compiler the tool supports. Both had to implement the in-memory method, so the test-only code existed twice even in the production bundle. Add a third compiler later and it exists three times. The test concern grows with the number of implementations.

Then there is every future change. Someone who fixes a real bug in one of the adapters still has to read past the in-memory method and understand why it is there, even though the fix has nothing to do with tests. You do not pay this cost only once, when the code is written. You pay it on every later visit and the people who pay it are working on something unrelated to the reason it exists.

Today many of those visits are made by a model. Every time you ask one to work on an adapter, it reads the whole interface into a limited context window, so the test-only method takes up room and attention on work that has nothing to do with it.

There is also a quieter cost. When a method sits on a production interface, a reader assumes production calls it. So a maintainer goes looking for the caller, finds only tests and has spent that time for nothing. The next reader makes the same wrong assumption and loses the same time.

Worse still, the method was also shipping to users. The adapters lived in the source directory, so the in-memory method was built into the published package. We shipped a path that only the tests ever used to everyone who installed the tool.

You cannot catch any of this with a compiler, a type checker, a linter or a test. Whether an interface is the right shape is a question about design and design is the one thing those checks do not measure. It stays hidden until a person reads the code and asks what each method is for.

Why a model reaches for this

A model writing code is trying to make the checks pass. When the goal is a passing test, the fastest move is almost always to add something to the thing under test, e.g. a method. Moving the design around so the test fits cleanly is slower and it does not help the next test pass. So a model leans toward making the interface wider and test-induced design damage is one of the plainest results.

People who are new to the trade do the same thing, for the same reason. I have watched many apprentices reach into a production class to add a method that only their tests need. The instinct is good. They want the code tested. The placement is what is wrong.

The fix

The repair removed no tests. We shrank the interface to what production actually asks for:

interface CompilerAdapter {
  sourceFiles(): SourceFile[];
}

We moved the choice of where the source comes from into how each adapter is built. You build one adapter with a tsconfig.json path and another with in-memory files. We then moved the in-memory adapters out of the source directory and into the test directory, where they belong. We no longer ship them. The tests cover exactly what they covered before.

The problem was never that testing hurts design. Full test coverage and a clean production interface do not fight each other. The damage came from putting the test-only path on the shared contract instead of at the point where each adapter is built. Move it and you keep both.

But the fix is not free. It left us with more adapters, not fewer. Each adapter is small and does one job and none of them has a method meant for something else. There are more pieces, but each piece has less on it. Over the life of the code, what costs you is not how many pieces there are but how much each one makes you account for. That is now less.

Read the code

The passing tests told me the code ran. They did not tell me the design was sound. That gap does not close because a machine wrote the code faster or cleaner than I would have. It may even widen. When code looks finished, you question it less than you would a messy draft.

Maybe I held the tool wrong. A sharper prompt or more planning up front might have led the model to a cleaner interface, and it often does. But that only changes the odds. It does not tell you, on any given change, that the design came out sound. The only thing that does is reading it.

A passing check is the start of the review, not the end of it. Read the code.