A Testing Strategy for a Codebase With No Tests
When you're asked to start testing a project that has none yet, the instinct is to open a file and start writing unit tests for whatever new feature is in front of you. That's usually the wrong place to start: you end up with tests for whatever features happened to be worked on, not for what the system actually depends on. A legacy system with zero coverage doesn't need that — it needs trustworthy boundaries you can lean on, and the fastest way to build them is to stop reading code and start looking for where the business is most fragile: payments, reservations, resource creation.
Start at the Endpoints That Can Collide
Not every endpoint deserves the same first look. The busiest endpoint in the system isn't necessarily the most dangerous one. The one worth testing first is the one where two things can happen at once and only one of them is allowed to win: two requests booking the same slot, two workers picking up the same job, a balance getting debited twice because a retry fired while the first request was still in flight.
Call these the conflict endpoints: places where correctness depends on ordering, on state that can change between the read and the write — or even code nobody's quite sure what it does anymore, that was never documented, and that everyone leaves alone because it's known to work. They're usually easy to find without reading much code: ask what would make a customer email you personally if it broke. That list is the real starting point — not "what's easiest to test," and not "whatever's new," since we're only just starting. It's the same instinct behind the first test worth writing in an untested file, applied now as a repeatable strategy instead of a single test.
Why the First Tests Are End-to-End
An end-to-end test1 drives a request through the real system the way a client would — over HTTP, through the actual routing and handler, into the actual database — and asserts on the response, not on any internal function. No mocking the parts you don't trust yet, because you don't know their shape well enough to mock them honestly.
That's exactly why it's the right first tool. You don't need to understand how the legacy handler is structured internally to write one; you only need to know what it's supposed to guarantee from the outside. For a booking endpoint, the guarantee is simple to state and easy to violate:
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../app";
describe("POST /reservations", () => {
it("lets only one of two concurrent requests for the same slot succeed", async () => {
const payload = { roomId: "12A", date: "2026-08-04" };
const [first, second] = await Promise.all([
request(app).post("/reservations").send(payload),
request(app).post("/reservations").send(payload),
]);
const succeeded = [first, second].filter((response) => response.status === 201);
expect(succeeded).toHaveLength(1);
});
});
This test doesn't care whether the handler is 300 tangled lines or a clean pipeline. It only cares about the contract: the input and the output. Send two conflicting requests and get exactly one success. That's what makes it safe to write on day one, against code you don't trust yet.
These tests do come with a real cost and a real maintenance burden, though — don't treat them as free coverage. On the cost side: end-to-end tests are slow because they're not testing your code in isolation — they're testing a real HTTP server talking to a real database, which means every run pays for process startup, network round-trips, and whatever state that database happens to be in. That cost adds up fast if you wrap every endpoint "just in case." On the maintenance side: these tests can fail for reasons that have nothing to do with the behavior they're supposed to protect — a migration renames a column, a fixture quietly drifts out of date, a shared test database gets left in a state the next run didn't expect. None of that is a regression in the code — it's maintenance debt in the tests themselves, and it accrues exactly like the debt in the legacy code you're trying to fix.
That's exactly why the flows you pick matter as much as the fact that you picked some. Once testing an endpoint this way is easy, resist the pull to wrap every one just because the mechanism now exists — the goal isn't coverage, it's a small number of tests you'll still trust in six months. Structure each one around a single scenario with a name that states what should happen, not the mechanism — that name doubles as documentation ("only one of two concurrent requests succeeds," not "test reservation endpoint"). Give each test its own fixtures instead of sharing state with its neighbors — a test that depends on data another test left behind is a test that fails for the wrong reason, and debugging that failure wastes exactly the trust the test was supposed to build. Revisit the set periodically, too: if a conflict endpoint gets rewritten and its risk moves elsewhere, the e2e test covering it should move with it, not linger as a fossil nobody remembers writing. A pile of brittle end-to-end tests is worse than none; a small, deliberately maintained set is a safety net you can actually keep — held to the same standard as the production code sitting next to it.
The Net Is What Lets You Refactor
Once that e2e test exists and passes, something changes: the code between the request and the response is no longer untouchable. You don't need to understand every line of the legacy flow before changing it — you need the e2e test to still pass after you change and refactor that code. That's the whole point of the test. It lets you decouple the tangle a piece at a time — if everything runs inside one giant function, pull the conflict check into its own function, pull the pricing logic into another — or introduce new services wherever the code is doing more than one job, to keep each responsibility simple.
The e2e test doesn't change during any of this. That outer layer keeps checking that the same input produces the same output while you reshape the inside underneath it — which is exactly what makes refactoring legacy code survivable instead of unbearable and terrifying.
Where Unit Tests Take Over
This is where unit tests earn their place, and where they're doing a genuinely different job than the e2e test. The end-to-end test verifies a contract from the outside: given this request, expect that response, regardless of how the internals work. A unit test verifies a much smaller contract, in isolation, for a single piece you just extracted — independent of the infrastructure, or of "the net," as we called it earlier:
import { describe, it, expect } from "vitest";
import { assertSlotIsFree } from "../reservations/slot-availability";
describe("assertSlotIsFree", () => {
it("rejects a slot that already has a confirmed reservation", () => {
const existing = [{ roomId: "12A", date: "2026-08-04", status: "confirmed" }];
expect(() =>
assertSlotIsFree(existing, { roomId: "12A", date: "2026-08-04" })
).toThrow("Slot already reserved");
});
});
assertSlotIsFree doesn't know about HTTP, routing, or the database. It knows one rule, and the unit test pins that rule down independently of everything around it. That's the real difference between the two kinds of tests: the e2e test protects the behavior the business depends on; the unit test protects the decision you just gave a name to. Neither replaces the other. The e2e test tells you the extraction didn't break anything observable; the unit test tells you the extracted piece is correct on its own terms, and will keep telling you that long after the surrounding code has changed shape again.
The first test in a legacy codebase should protect a promise the system makes to someone. Every test after that should protect a promise some piece of code makes to the rest of the system.
Repeat that extraction enough times — pull a rule out, give it a name, give it a unit test, confirm the e2e test still passes. Legacy code doesn't get rewritten in one dramatic pass: it's a job that takes time. It gets replaced one contract at a time, with a net underneath it the whole way down.
Conclusion
A testing strategy for a codebase with nothing isn't a coverage target — it's a sequence. Find the endpoints where the system can contradict itself, wrap those in a small, deliberately maintained set of end-to-end tests, and use the safety that buys you to decompose the code underneath. Each piece you pull out gets its own unit test and its own contract, independent of the HTTP layer that used to be the only thing verifying it existed at all. That's how a legacy system becomes a tested one: not by testing everything at once, but by testing the one thing that would actually hurt to get wrong, and letting that be the foothold for everything after it.
Footnotes
-
End-to-end test: a test that drives a request through the real system the way a client would, over HTTP, through the real handler, into the real database, and asserts on the response. ↩
Related articles
Get new articles in your inbox
Writing about pragmatic architecture, maintainable systems, and real-world engineering trade-offs. Published when ready.