How to Test a Legacy System That Has No Tests in 2026
The Quick Answer
You cannot test a legacy system the way you test a new one, because the usual starting point - a specification saying what the code should do - does not exist. The behavior is the specification, bugs included, and some of those bugs are load-bearing.
The move that unlocks everything else: stop trying to test whether the system is correct and start capturing what it currently does. Those tests are called characterization tests, and their purpose is not to prove the code right - it is to make change detectable.
Where to start: not at the top of the codebase, and not with a coverage target. Start at the code you are about to change, and only widen from there.
This article covers how to decide where the first tests go, how to write characterization tests for code you do not understand, how to find seams in code that has none, what to do about untestable dependencies, and how to build a safety net solid enough to refactor behind. For measuring what you end up with, see our test coverage guide.
What Makes Legacy Different
"Legacy" here means the working definition that is actually useful: code you are afraid to change. Age is incidental. A two-year-old service with no tests and one remaining author is legacy; a fifteen-year-old system with a solid suite is not.
Four properties make the testing problem different in kind, not just in degree.
There is no oracle. Requirements documents, if they exist, describe a system that was superseded years ago. Nobody can tell you whether the rounding in the invoice calculation is a rule or a mistake. You are reverse-engineering intent from behavior, and often the honest answer is that the current behavior is the requirement now, because customers have built processes on it.
The code resists being called. Legacy code typically has no injection points: constructors open database connections, business logic lives inside UI event handlers, static singletons hold state between calls, and the whole thing assumes a specific environment. You cannot write a unit test because you cannot instantiate the unit.
The dependencies are real. A file share, a mainframe, a scheduled job, a licensed third-party component, a database with two thousand tables and no documented constraints.
Bugs have become features. Somewhere in the system is a defect that a customer's downstream process depends on. Fixing it during a refactor - as a bonus - will cause an incident. This is why characterization tests capture current behavior rather than correct behavior, and why "wrong" results sometimes get pinned deliberately with a comment explaining why.
Before You Write a Test: Map the Risk
The instinct on inheriting an untested system is to start writing tests at the top of the file tree. That produces a lot of tests for stable code that nobody touches, and none for the module that changes weekly.
Spend the first days building a map instead. Four inputs, none of which require understanding the code:
- Change frequency. Version control tells you which files have changed most in the last year. Frequently changed code is where regressions come from, by definition.
- Defect history. The tracker tells you where bugs concentrate. Overlay it on change frequency and the hot spots become obvious.
- Business criticality. Which flows lose money, break a contract, or trigger a regulatory obligation when they fail.
- What is about to change. The roadmap. Tests written for code nobody will touch this year are inventory, not safety.
Where change frequency, defect density and business criticality overlap, that is your first target - regardless of whether it is the ugliest code in the system. The ugliest code that has not been modified since 2019 and has never produced a defect is not urgent; leave it alone.
One useful artifact from this exercise is a written list of the system's critical behaviors - the twenty or thirty things that must not break, phrased as observable outcomes rather than implementation. That list is the backlog for everything that follows, and it is worth keeping somewhere durable as test cases rather than in a document, because it will be worked through over months.
Characterization Tests: Pinning Down What It Does Now
A characterization test asserts current behavior, whatever it is. It carries no claim of correctness. Its only job is to fail when behavior changes.
The technique for code you do not understand is deliberately mechanical:
- Write a test that calls the code with some plausible input and asserts something you know is wrong - that the result equals
"placeholder". - Run it. The failure message tells you the actual value.
- Replace the expected value with the actual one. The test now passes.
- Repeat with different inputs, especially around branches you can see in the code.
It feels like cheating and it is not. You have converted unknown behavior into recorded behavior, and from this point any change that alters it is visible immediately. That is the entire objective.
Three things to keep in mind while doing it. Do not fix anything yet - if a result is clearly wrong, pin it anyway and leave a comment saying so; correcting behavior and restructuring code at the same time is how you lose the ability to tell which one broke things. Go for branch coverage over input volume - twelve inputs that each traverse a different path are worth more than two hundred that all take the same one. And keep an eye out for the inputs that produce nonsense, because they are the fastest route to understanding what the code was built to assume.
Finding Seams
A seam is a place where you can change behavior without editing the code at that point. Legacy code has few, which is why it resists testing. The work of making it testable is largely the work of introducing them - with the smallest possible edits, since every edit is unprotected until a test exists.
| Obstacle | Smallest safe move |
|---|---|
| Constructor opens a DB connection or reads config | Add a constructor that accepts the connection or config; the existing one keeps its behavior and delegates to it |
| Business logic buried in a UI handler or controller | Extract the logic verbatim into a plain function; the handler just calls it |
Hard-coded new of a dependency | Pass it as an optional parameter defaulting to the current behavior |
| Static singleton holding state | Add a reset hook for tests, or wrap access behind an interface |
| Direct calls to clock, filesystem, network, randomness | Route through a thin wrapper you can substitute |
| Private method with the logic you need to test | Test through the public entry point, or extract to a separate collaborator |
The ordering rule is the one thing to be strict about: make the change that enables the test as small and as mechanical as possible, prefer edits your IDE performs automatically, and get a test in place before doing anything creative. The dangerous moment in legacy work is the gap between "I need to restructure this to test it" and "the test exists".
Sometimes there is no small edit. When a component is genuinely inseparable from its environment, do not fight it - test it from further out, at the API, the CLI, the queue message or the generated file. A slower test at a coarser boundary that actually exists beats an elegant unit test that requires a week of restructuring first.
Where to Spend the First Ten Tests
The first tests set the direction, and the temptation is to spend them on the code that is most interesting rather than the code that is most exposed.
- One end-to-end smoke test of the primary flow. Whatever the system exists to do - process the batch, produce the invoice, complete the order. Crude, slow and manual-if-necessary. It is the tripwire.
- Two or three characterization tests on the highest-risk calculation. Pricing, tax, interest, eligibility - wherever a wrong number has consequences.
- One test per major branch of the module you are about to modify. This is the safety net for the actual work.
- Two tests around the boundary you are most likely to break. The file format, the API response shape, the database contract - whatever other systems consume.
- One test capturing the last production defect. Free evidence that it matters and free evidence that it recurs.
- One test for the behavior everyone says is fragile. The thing people warn newcomers about is usually fragile for a reason.
Ten tests will not give you meaningful coverage of a large system, and pursuing a coverage number at this stage is the wrong goal. What ten well-placed tests give you is the ability to make the next change without holding your breath, and that is what turns a stalled system back into one that can be worked on.
Building the Safety Net Around a Refactor
When the goal is restructuring rather than adding behavior, the safety net has a particular shape: it should be sensitive to the outputs the refactor must preserve and indifferent to the internals it is allowed to change.
Pin the boundary, not the implementation. Tests that assert which private methods get called in which order will all fail during a refactor, telling you nothing except that you refactored. Assert what comes out the other side.
Use approval testing for wide outputs. When the result is a document, a report, a file, an HTML page or a large object graph, hand-writing assertions is impractical. Run the current code, capture the entire output as an approved snapshot, and have the test compare against it. The diff on failure is exactly what changed. This is the highest-leverage technique available for legacy work, because it produces broad behavioral coverage in minutes.
Consider running old and new in parallel. For a component being replaced rather than restructured, run both implementations against real production inputs, compare the results, and log the differences - without using the new one. This is only safe when the shadow path has no side effects of its own: if the component writes to a database, publishes messages or calls another service, stub or redirect those actions in the new implementation first, or every production input gets processed twice. Days of real traffic is a far better test suite than anything you would have written, and it works especially well for calculation engines and data transformations.
Then refactor in small, verified steps. Change, run the suite, commit. When the suite is slow, run the affected subset locally and the whole thing in CI. The commit granularity is the recovery granularity, and on legacy work you will use it.
Keep the regression pass around after the refactor lands. The characterization tests you wrote to enable the work become the regression suite that protects it afterwards, which is the main reason this effort compounds rather than being consumed by the project that prompted it.
When You Cannot Automate It
Some legacy systems resist automation for reasons that are not going to change on your timeline: a licensed component with no test hooks, a green-screen terminal interface, a hardware dependency, an environment that can only be provisioned by hand.
The answer is not to give up on the safety net; it is to build it from documented manual checks instead, and to hold them to the same standards you would hold automated ones.
- Write them down as reusable cases, with exact inputs and exact expected outputs, so two different people get the same result. An undocumented manual check is not a test.
- Version them with the system. When behavior changes deliberately, the case changes with it, and the history explains why.
- Assemble them into a defined regression pass that runs before each release, with results recorded run by run - so "did we check the month-end batch this time" has an answer.
- Automate the setup even when you cannot automate the check. Seeding data, provisioning the environment and generating input files are usually scriptable even when the verification is not, and that is where most of the manual effort actually goes.
A structured manual regression pass held in a test run, with its history visible, is a legitimate safety net. An undocumented one that lives in one experienced tester's memory is a single point of failure with a notice period.
Making It Sustainable
Legacy testing projects fail in a predictable way: a burst of effort, a coverage number that plateaus, attention moves elsewhere, and eighteen months later the system is untested again except for a folder of tests nobody runs.
Three practices prevent that.
Tie test writing to change, permanently. The rule is simple and it works: any code you modify gets characterized first. No separate testing project, no budget line, no end date. Coverage grows where the work happens, which is exactly where it is needed, and it costs nothing extra because you needed the safety net for the change anyway.
Run whatever you have, automatically, from day one. Three tests running on every commit are worth more than three hundred that run when someone remembers. A suite that is not in the pipeline decays within weeks.
Report progress in risk, not in percentages. "Coverage rose from 4% to 11%" means nothing to anyone funding the work. "The invoice calculation, the payment export and the nightly reconciliation now have regression tests, and the last two releases had no escaped defects in those areas" is the same fact in terms that keep the work funded.
Conclusion
Testing a legacy system is not a smaller version of testing a new one. There is no specification, the code resists being called, the dependencies are real, and some of the bugs are contractual. The response is to invert the usual order: capture behavior first, understand it second, correct it last.
The practical sequence is consistent across systems. Map where change, defects and business risk overlap. Write characterization tests there, pinning current behavior without judging it. Introduce the smallest possible seams to make the code callable, getting a test in place before anything creative. Use approval tests and parallel runs to get broad coverage cheaply. Then refactor in small steps behind the net you built.
The hardest discipline is restraint - not fixing the bug you found, not restructuring before the test exists, not chasing a coverage number across code nobody touches. Testing follows the work, and after two or three changes the system stops being the one nobody wants to open.
What makes it compound is that the safety net survives the project. QA Sphere keeps that net - automated results pushed from CI and structured manual passes for the parts that resist automation - as versioned test cases with run history and reporting, so the coverage you build on a legacy system is still there for the next person who has to change it. See pricing or book a demo.
Written by
QA Sphere TeamThe QA Sphere team shares insights on software testing, quality assurance best practices, and test management strategies drawn from years of industry experience.



