Blog / How to Add Tests to a Legacy Codebase With Zero Coverage
How-To

How to Add Tests to a Legacy Codebase With Zero Coverage

Legacy code rarely has the seams unit tests need. Put a characterization net around it from the outside first, then refactor and test inward.

On a codebase with zero coverage, the code you most need to protect is usually the code a unit test cannot reach. A function that reads its database connection from a global and takes an object no test can build will not run in isolation until you refactor it, and refactoring it untested is the risk you set out to remove.

Unit tests and refactoring end up waiting on each other, and the way out starts outside the code. Here is how to put a net around the running system first, then work inward.

What you’ll learn

  • Why “legacy code” now includes code you shipped last week, AI-generated code included
  • The outside-in order that works, with characterization tests first and unit tests after the refactor
  • A step-by-step for a codebase with zero coverage, including a characterization test in Jest you can run
  • How much of that first net you can generate, and which calls stay with your team

Legacy Code Is Just Code Without Tests

Legacy code is a question of tests, not age. The definition that survives contact with a real repository comes from Michael Feathers, in Working Effectively with Legacy Code (2004).

To me, legacy code is simply code without tests.

What makes code legacy is that you cannot change it and quickly know whether you broke something. A service you shipped last Tuesday with no tests already qualifies.

The definition matters more now than it did in 2004, because of how code arrives. A model can write a working module faster than anyone reviews it, and the engineer who merges it may have read it once. Nobody on the team can reconstruct why it does what it does, and no test will speak up the day it stops. Adding tests to a legacy codebase and testing AI-generated code are the same job wearing two hats.

Why You Start Outside-In, Not With Unit Tests

Start from the outside of the system with high-level behavioral tests, because legacy code is rarely modular enough to instantiate in isolation. Nicolas Carlo, who writes at Understand Legacy Code, compresses it to one line. “Start testing from the outside of the code, then refactor from the inside.”

Unit tests want seams and small, isolatable units, and untested legacy code hands you neither for free. Insisting on them first means refactoring before you have a single test to refactor under, which is the loop from the top of this page.

The pyramid you were taught, lots of unit tests over a few end-to-end ones, describes the codebase you want to end up with. On day one of a legacy effort the economics invert. Of the automation tools built for end-to-end testing, the ones that suit this job need the least wiring into code you do not understand yet.

Test typeRefactor needed first?Speed to a safety netWhat it catches on legacy codeBest first move for
End-to-end / characterizationNone. Drives the real UI or API.Fastest. Cover a critical flow in one test.Broken user journeys, integration and wiring bugs, wrong-environment config.Getting any net up on tightly coupled code you do not yet understand.
IntegrationSome. Needs a runnable slice of the system.Medium. Good once the app boots in CI.Contract breaks between modules, database and API mismatches.Locking down a subsystem you are about to change.
UnitUsually a lot. Needs seams and isolatable units.Slowest to start. Fast to run once they exist.Logic errors in a single function or class.The parts you have already refactored under the net.

The move that breaks the loop is the characterization test. It records what the code does today, quirks included, and fails the moment a change alters that. You drive the real entry point, capture what comes out, and lock it in as the expected result.

Whether that output is correct is a question for later. A characterization test is almost like stretching a thread across a doorway, because you do not need to understand the room to know somebody walked through it.

Once that net is up, refactor the code you need to change and add unit tests underneath it while the high-level test keeps watch. On tightly coupled code, outside-in is the order that gets you a net fastest. It reaches behavior through the front door instead of through surgery on internals the team cannot explain yet.

None of this makes unit tests wrong. They are the destination. TLDR on placement, you earn a healthy pyramid by surviving long enough to refactor toward it, and the high-level tests keep you alive on the climb.

The Order That Holds

Get the app running in CI, write characterization tests around your top flows, then refactor with those tests as the net. Unit tests come after the refactor, never before it.

How to Add Tests to a Legacy Codebase, Step by Step

Adding tests to a codebase with zero coverage is a sequence, and each step earns the next one. The first pass aims for a net over the flows that would hurt most if they broke. Comprehensive coverage can wait.

  1. Get it running in CI: Before any test, make the app start reproducibly and wire a CI job that can run a test command, even an empty one. Teams skip this because it is unglamorous, and every later step assumes it. If you cannot boot the system on demand, you cannot test it.
  2. Rank flows by blast radius: List the handful of journeys that generate revenue or would trigger a 2 a.m. page, such as checkout, login and the core create-and-save loop. Skip the file tree and start where a user would notice.
  3. Characterize current behavior: For each top flow, drive it through its real entry point, the UI, the API or the CLI, and assert on what it does today. Do not fix bugs yet. If a flow does something weird, capture the weird thing. You are recording reality, and correcting it comes later.
  4. Prove each test can fail: A test written around code you do not understand can pass for reasons unrelated to the behavior you meant to capture. Comment out a line the flow depends on, or mangle an input, and confirm the test goes red before you trust it, then restore the code. A green suite wired to nothing is worse than no suite, because you will make decisions on it.
  5. Gate merges in CI: Run those tests on every pull request and block merges that turn them red. That gate is the moment the codebase stops getting more dangerous. New changes now have to prove they did not break what you covered.
  6. Refactor, then unit test: Now start improving the code you need to change. As you introduce seams to make a module testable in isolation, add unit tests for it. Coverage grows from the outside in, following work you were going to do anyway.
  7. Test every bug you fix: From here on, every production bug gets a failing test that reproduces it before the fix lands. Bug-driven tests make coverage compound in the places that break, instead of the places that were easy to reach.

What Steps 3 and 4 Look Like in Jest

Here is step 3 against a legacy Express checkout route. Jest snapshots record the response, and Supertest boots the app on a throwaway local port, so nothing needs deploying. If the app file calls listen() as it loads, move that one call into a separate server file first.

// test/checkout.characterization.test.js
const request = require('supertest');
const app = require('../src/app'); // the legacy Express app, exported without listen()

test('POST /checkout returns what it returns today', async () => {
  const res = await request(app)
    .post('/checkout')
    .send({ items: [{ sku: 'TEE-01', qty: 3 }], coupon: 'SPRING10' });

  expect(res.status).toBe(200);
  expect(res.body).toMatchSnapshot({
    orderId: expect.any(String),   // random on every call
    createdAt: expect.any(String), // a timestamp
  });
});

The first run writes the response to a __snapshots__ file and passes. That file is the characterization, today’s total with its quirks included. The two property matchers matter more than they look. An order ID and a timestamp change on every call, and without expect.any(String) the snapshot goes red on the very next run for no reason.

Then step 4. Comment out the line in the route that applies the coupon, rerun, and Jest rejects the new total, 64.77 where the snapshot recorded 58.29.

- Snapshot  - 1
+ Received  + 1

-   "total": 58.29,
+   "total": 64.77,

Restore the line and commit the __snapshots__ folder with the test. Step 5 then comes nearly free, because Jest switches to CI mode in most CI environments and fails on a missing snapshot instead of writing a fresh one. An uncommitted baseline shows up as a red build.

Read every snapshot diff before you rerun with -u, since a blind update turns the net into a rubber stamp. In Python, pytest with the syrupy plugin gives you the same record-and-compare workflow through a snapshot fixture, and pytest --snapshot-update records the first baseline.

Stop Typing the First Test Net

Let Pie map your app's flows. Keep the tests that matter.

Generate the First Test Net Instead of Typing It

Typing the first layer of characterization tests one flow at a time is the slow part of outside-in, and it is the part AI has changed. An AI QA agent can explore a running application through its UI, find the flows it can reach, and generate end-to-end tests for them, without reading the source and without a refactor.

You give Pie, our autonomous QA platform, a staging URL, test credentials and any setup notes your app needs. It explores the app the way a first-time user would and turns the flows it reaches into an end-to-end suite, for a codebase it was never wired into. Each test asserts what a user would expect from the flow. When the agent reads a quirk as a bug, it comes back as an issue for your team to accept or reject, so old behavior gets a decision instead of being locked in.

Zero coverage is the starting point for autonomous test discovery, since the full discovery run expects an app with no test cases in Pie yet. Exploration fans out across parallel agents, and results land in hours, not weeks.

Pie dashboard listing discovered key features with bug and test case counts
Discovery groups what it finds into key features, each with its own test cases and issues.

The net also has to survive the cleanup it protects. End-to-end tests pinned to CSS selectors and XPaths break when a refactor renames the classes and IDs they target, so keeping them green turns into a chore exactly when you are changing the most code. Pie executes vision-first and you can author supported tests without maintaining selectors, so a renamed component or a moved button can leave a test untouched. When a cleanup changes what a flow does on purpose, edit that test’s steps and rerun it.

Three limits decide how smoothly the first run goes.

  • Staging capacity: Parallel exploration puts heavy load on staging, so concurrency is adjusted to what your environment can take. A staging box that buckles easily means a slower first run.
  • Flags and seed data: Exploration reaches what the test account can see. A feature behind a flag, or one that only appears once certain data exists, stays dark until you switch the flag on or seed the account. A leaderboard screen needs enough players in the data before there is a leaderboard to test. Put both in the setup notes.
  • First-run curation: The first suite comes back with useful test cases mixed in with haywire ones that do not matter. Your team reviews it and decides which cases stay.

Setup lives in the quickstart guide, and nothing gets installed in your repository. The curation that follows is the real work, and nobody should sell it to you as zero.

A small footprint matters most on code you are afraid to touch, and so does speed. A mobile engineering lead at one of our customers, which started from zero coverage, told us that writing it all by hand he would probably have made it as far as the login screen.

Three Mistakes That Stall Every Legacy Test Effort

Three things kill a legacy test effort, and none of them are the code.

  • Coverage targets: A target aimed at a number rewards testing trivial getters and avoids the tangled, high-risk code that is both hardest to test and likeliest to break. On legacy code, a coverage percentage measures the wrong thing. Cover flows by blast radius and let the number follow.
  • Refactoring first: Cleaning the code before testing it means editing code you cannot verify, which is the loop from the top of this page.
  • Forced unit tests: Spending days extracting seams so one function can be unit-tested, while checkout still has zero coverage, is optimizing the wrong layer. Ask which problem you have first. Code you cannot reach, or code you cannot trust. If it is the first, go around it through the UI, and the unit tests get much cheaper later, once a feature change has you refactoring that module anyway.

Get the Net Up First

On a legacy codebase the inside-out order is the expensive one. Every unit test waits on a refactor you cannot verify, and teams that start there are still arguing about dependency injection weeks later while checkout sits uncovered. A test that drives the app from outside needs nothing but a running copy of it.

What changed is who types the first layer. Pie explores the running app, turns the flows it reaches into end-to-end tests, and reruns them on a schedule or from your CI. Put that net up first, and the refactor you have been putting off finally has something underneath it.

Inherited a Codebase With No Tests?

Bring a staging URL. Get a first net without touching the code.

Frequently Asked Questions

Start with end-to-end or other high-level behavioral tests. Legacy code is usually too coupled to instantiate in isolation, so unit tests demand a refactor before any net exists. Characterize today's behavior through real entry points first, then add unit tests as you refactor.
Skip the percentage target. On legacy code a coverage number rewards testing trivial getters and avoids the tangled flows most likely to break. Rank flows by revenue and blast radius, cover the top handful end to end, then expand from there.
No, and that order is the classic trap, because refactoring untested code means changing code you cannot verify. Write characterization tests around existing behavior through the highest entry point you can reach, then refactor underneath them. An end-to-end test sidesteps the coupling entirely.
A characterization test records what code does today, quirks included, and fails when a change alters that behavior. It makes no claim about correctness. Snapshot tests and approval tests are the same idea, and they give you the signal you need before touching untested code.
Prove it can fail before you trust it. Comment out a line the flow depends on, or mangle an input, and confirm the test goes red, then restore the code. A test written around code you do not understand can pass for the wrong reason.
Yes, and the outside-in approach is where it helps most. An AI agent can explore a running app through its UI and generate end-to-end tests of current behavior without reading the source. Expect to review and prune the first run before you rely on it.
Pie explores your running app like a user and generates end-to-end tests of the flows it reaches, with no code added to your repository. You provide a staging URL, test credentials and any setup notes. Execution is vision-first, so renaming a component during cleanup need not break a test.
Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →