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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.