Skip to content
All letters

TDD in Vertical Slices, Not Horizontal Layers

One test, one impl, one user-visible thing. Repeat. Stop arguing about coverage.

The argument that has consumed more team hours than almost any other engineering argument is the test coverage argument. How much do we test, what do we test, when do we test. Most of the heat in that argument disappears once you pick a direction for the testing, vertical, not horizontal, and a unit, public interface, not internal function. Once those two are pinned, the rest is mechanical.

This is a short post about both choices and why I now refuse to argue about either.

The horizontal mistake

The default approach to testing, taught in roughly every introductory course and reinforced by every code coverage tool, is horizontal. You test the data layer thoroughly. Then the service layer. Then the controller layer. Each layer gets its own test suite. Each test exercises one layer in isolation, with the other layers mocked or stubbed.

This is the approach that produces ninety percent code coverage, a green CI pipeline, and a production bug on the first user interaction.

The reason it fails is that the layers don't fail in isolation. The bugs that matter live in the seams between layers. A service-layer test that mocks the data layer cannot find the bug where the service interprets the data shape one way and the data layer returns another. A data-layer test that mocks the service cannot find the bug where the service was supposed to enforce a constraint that the data layer didn't. The seam is exactly where the test isn't.

Horizontal testing optimizes for a metric, coverage, that does not correlate with the property we actually care about, which is whether the user can do the thing they came to do.

The vertical alternative

Vertical testing picks one user-visible behavior, log in with a valid password, create an item and see it on the list, submit a form and receive the email, and tests that behavior end-to-end, through whatever layers it touches, with as little mocking as you can get away with.

One test. One implementation. One thing the user can actually do. Then the next thing. Then the next. You build the product in slices that go all the way from the click to the database and back.

This is closer to what the original TDD literature meant before "TDD" got hijacked by the coverage discourse. The point was never that every function should have a unit test. The point was that the test came first, and the test described user-visible behavior, and the implementation existed only to make the test pass.

A vertical slice is the smallest possible end-to-end change that makes the system do something it didn't do before. You write the test for that something. You make it pass. You commit. You take the next slice.

The public interface rule

The companion choice, and the one that quietly resolves most of the "what should I test" arguments, is that tests target the public interface, not internal functions.

The public interface is whatever your callers actually call. For a web app, it's HTTP requests in and responses out. For a library, it's the exported API. For a CLI tool, it's the command line and the exit code and stdout. For an agent, it's the prompt and the final answer.

Anything that isn't the public interface is implementation detail. Implementation detail is the stuff you want to be able to change without breaking tests. If you write a test against an internal helper function, every refactor breaks the test, even when the user-visible behavior didn't change. The test is now your enemy.

Tests against the public interface, on the other hand, survive refactors. The implementation can change shape entirely; the test still describes what the system does. This is what test suites are supposed to be, a description of the contract, not a snapshot of the current implementation.

The test that paid for itself

The clearest case I have is a recent one. I built a review step that decides whether a draft essay may be published. Its public interface is a single exit code: zero means publish, anything else means hold. So the tests drive it through that exit code, with a live reviewer reply, rather than poking at the functions inside.

They caught a bug before the step ever ran for real. The verdict parser matched the word "clean" anywhere in the reviewer's reply. A reply that quoted a problem whose context happened to contain that word read as a pass, so a draft that should have been held would have gone out. A test that fed the parser the replies I expected would most likely have passed. The test on the exit code, with a real reply, failed. The fix was to read the verdict token instead of scanning the whole line.

Why this maps well onto LLM workflows

The shift to LLM-assisted coding makes the vertical-slice rule even more important than it was in pure human coding. Here's why.

An LLM is excellent at writing code that satisfies a specific test. It is mediocre at deciding which tests should exist. If you give it a vertical-slice test, "user can do X", it produces an implementation that does X. If you give it free rein to write tests for whatever functions feel testable, it produces a lot of tests for things that don't matter, and the resulting coverage tells you nothing.

The discipline that survives the shift to AI coding is the discipline of describing what should happen, not how it happens. Vertical-slice tests describe what should happen. They are the only kind of test where the LLM's "make it pass" instinct actually aligns with the property you care about.

The anti-pattern: tests for code you wish you had

A failure mode I've watched people drift into, including me: writing tests for code that doesn't exist, then never quite finishing the code, but updating the test until it passes against the stub.

This is the test-driven version of the lie. The test was supposed to drive the implementation. Instead the absence of an implementation drove the test to be weak enough not to fail. The test passes. The feature is fictional.

The mechanical fix is simple. The test must fail when run for the first time, in a specific way, the way that proves the system actually cannot do the thing yet. If the test passes the moment you write it, you wrote the wrong test. If it never fails, you're not doing TDD; you're doing post-hoc documentation.

The psychological fix is harder. You have to be willing to look at a red test and resist the temptation to soften it. The red test is the point. The red is what tells you what to build.

The discipline of the loop

Red. Green. Commit. Then, only then, refactor. The order matters.

Red, write the failing test that describes the next user-visible behavior. Run it. See it fail. The failure mode should be informative; if it's not, the test is wrong.

Green, write the smallest possible code that makes the test pass. Not the best code. Not the cleanest code. The smallest. If the code is ugly, fine. Refactor is a separate step.

Commit, before any refactor, capture the green state in a commit. This is the cheapest safety net you have. If the refactor breaks something, you can return to the green commit instantly. If the refactor improves the code, the commit history shows the work cleanly.

Refactor, now improve the code, with the test as your safety harness. The test should stay green through the refactor. If it goes red, you've changed behavior, not just shape; back out and reconsider.

The whole loop, done right, is ten or twenty minutes per slice. A morning produces ten to twenty slices. A week produces a feature.

What you get for the discipline

You get a system whose tests are descriptions of what the system does. You get a refactor process that doesn't terrify you. You get a coverage number that, while it isn't ninety percent, actually means something. Every line under test exists because some user-visible behavior depends on it.

And you get out of the coverage argument forever. You don't argue about whether to test private functions. You don't argue about mocking strategies. You write one test that describes one thing the user can do, and you make it pass, and you do that again. The arguments evaporate because there's nothing left to argue about.


The rule is short. The hard part is the discipline of staying inside it when the slice looks too small to bother with. The slice is never too small.

Back to all lettersayal.tech / letters