Testing Strategies for a Legacy Codebase

By James Nguyen Updated September 24, 2026
Testing Strategies for a Legacy Codebase

Inheriting a seven-year-old codebase with almost no test coverage and a standing rule of "don't touch the billing module, nobody remembers exactly how it works" is a specific kind of dread. Adding tests to code that predates any testing culture at the company required a different approach than starting a test suite from scratch, and most testing advice assumes the greenfield case.

Resisting the Urge to Rewrite First

My first instinct was that this code needed a rewrite before it could reasonably be tested. That instinct is almost always wrong, and acting on it on a legacy codebase without a safety net is how six-month rewrites that never ship happen. The actual first move is writing characterization tests, tests that document what the code currently does, correct or not, before changing a single line of implementation.

Characterization Tests as the Starting Point

A characterization test calls the existing function with realistic inputs and asserts on whatever output it currently produces, even if that output looks wrong. The goal isn't correctness yet, it's a safety net: once that test exists, any refactor that changes the output becomes visible immediately, which is the only way to touch scary code with any confidence at all.

def test_current_discount_behavior_characterization():
    # Documents existing behavior, not necessarily correct behavior
    result = calculate_discount(order_total=150, customer_tier="gold", coupon="SAVE10")
    assert result == 121.50  # this is what it does today, not what it should do

Finding Seams Without a Dependency Injection Framework

Legacy code written before dependency injection was a habit tends to instantiate its own dependencies directly inside functions, a database connection, an email client, created inline rather than passed in. Michael Feathers' concept of a "seam", a place where you can alter behavior without editing the code in that place, mostly meant extracting a thin wrapper function around the hard dependency and monkey-patching or mocking that wrapper in tests, rather than a larger refactor to proper constructor injection all at once.

Testing Around the Edges Before Testing the Middle

Rather than trying to unit test every internal function of a tangled module, integration-style tests that exercise the module through its actual public entry points, an API endpoint or a CLI command, gave more coverage per test written and were far more resilient to internal refactoring, since they don't care how the middle of the module works as long as the boundary behavior stays correct.

Coverage Numbers as a Compass, Not a Target

Chasing a coverage percentage target across the whole codebase led the team toward testing the easy, low-risk code first, since that's what moves the number fastest, while the actual scary billing module stayed untested because it was hard. Switching to tracking coverage specifically on files being actively modified, via a diff-coverage check in CI, redirected effort toward the code that was actually changing and therefore actually risky.

The Strangler Fig Pattern for Gradual Replacement

For the pieces that genuinely did need rewriting, not just testing, routing new traffic through a new, well-tested implementation while the old code path stayed in place for existing cases, gradually expanding what the new path handles, avoided the all-or-nothing rewrite risk. This took longer calendar time than a rewrite sprint would have promised, but it never once required a rollback of the entire billing system.

Getting Buy-In for Time Spent on Non-Feature Work

None of this happens without deliberately carving out time against feature deadlines, and the argument that actually worked with leadership wasn't abstract code quality, it was pointing at the specific incidents in the last quarter that traced back to changes in untested code, and framing test coverage as incident prevention with a number attached rather than a vague engineering nicety.

Golden Master Tests for Output That's Hard to Assert On Directly

For a report-generation module producing a large, structurally complex output, writing individual assertions for every field would have taken longer than the refactor itself. Capturing the current output as a stored "golden" file and asserting future runs match it byte for byte, then deliberately reviewing and updating the golden file whenever a change intentionally alters the output, gave broad protection against unintended regressions without hand-writing dozens of narrow assertions.

Pairing With the One Person Who Remembers Why

Some of the gnarliest legacy code had no documentation, no tests, and no comments explaining an odd conditional, and the fastest path to understanding it wasn't reading the code harder, it was finding the one engineer who'd been there when it was written and pairing with them for an hour. That conversation surfaced a since-fixed edge case in an upstream payment provider that the strange conditional existed specifically to work around, context that would have taken days of git archaeology to reconstruct alone.

Flaky Tests Erode Trust Faster Than Missing Tests Do

A handful of early tests we wrote against the legacy scheduler module turned out to be time-dependent and failed intermittently depending on when CI happened to run them. A team that's still building trust in a new testing habit abandons that habit fast once a red build stops meaning anything, so we treated fixing or deleting flaky tests as a higher priority than writing new ones for a few weeks, specifically to protect the credibility of a red CI run meaning something actually broke.

Final Verdict

Testing a legacy codebase isn't about achieving textbook coverage, it's about building just enough of a safety net around the scariest, most-changed code that refactoring stops being terrifying. Characterization tests first, seams for the hardest dependencies, and diff coverage instead of a global target did more for actual reliability than any coverage dashboard number would have suggested on its own.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles