Write Characterization Tests Before You Refactor
You cannot safely refactor untested legacy code. Characterization tests capture what the code actually does so you can change it without breaking behavior.
You cannot safely refactor code you cannot verify, and most legacy code has no tests worth the name. The answer is not to write the tests you wish existed, the ones that check what the code should do. It is to write characterization tests: tests that capture what the code actually does right now, correct or not, and lock that behavior in place so you can change the structure without changing the behavior. This is the single most important skill for working in legacy code, and almost nobody teaches it, because it feels backward. You are not testing for correctness. You are testing for sameness. That distinction is the whole game.
Why characterization tests are different
A normal test asserts intended behavior: given this input, the function should return that, because that is what the spec says. A characterization test asserts observed behavior: given this input, the function currently returns that, so keep it that way through the refactor. The difference matters because legacy code has no spec you can trust. The behavior is the spec. Every weird edge case the code handles is there because someone hit it in production years ago, and you do not know which of those behaviors a customer secretly depends on. If you refactor to match what the code "should" do, you will helpfully fix bugs that turn out to be load-bearing, and something downstream will break. Characterization tests protect you from your own good intentions.
This is why I said in when not to refactor legacy code that untested working code is the most dangerous thing to touch. Characterization tests are how you make it safe to touch, and if you are not willing to write them, that is your signal to leave the code alone.
How to write them
The method is mechanical, which is what makes it reliable even in code you barely understand.
Pin the current output. Call the code with a range of inputs and record whatever it returns, including the ugly cases: nulls, empty inputs, boundary values, malformed data. You are not judging whether the output is right. You are recording what it is. Some frameworks call these approval tests or snapshot tests, and the idea is the same: capture the actual result and assert it stays constant.
Cover the branches, not just the happy path. Use the code's own complexity to guide you. A function with high cyclomatic complexity has many paths, and each is a behavior you need to pin before you refactor. This is a concrete use for the complexity signals in churn vs complexity hotspots: the complexity number tells you how many characterization tests you probably need.
Let coverage tools find the gaps. Run coverage while the characterization tests execute. The uncovered lines are behaviors you have not pinned yet, and those are exactly the lines where a refactor could silently change behavior with no test to catch it. A tool like ReformCode shows you coverage against complexity so you can see which high-complexity, low-coverage regions are riskiest to enter without more tests. That map tells you where to focus the characterization effort before you cut.
Do not fix bugs yet. If a characterization test reveals the code does something clearly wrong, resist fixing it during the refactor. Pin the wrong behavior, refactor to preserve it, ship, and fix the bug as a separate, deliberate change afterward. Mixing a bug fix into a refactor means that when something breaks, you cannot tell whether the refactor or the fix caused it. Keep the changes separable.
Then refactor with confidence
Once the behavior is pinned, refactoring becomes a tight loop: change the structure, run the characterization tests, and if they still pass, your behavior is preserved. If one fails, you changed behavior you did not mean to, and you know immediately, at the smallest possible increment. That fast, certain feedback is what turns refactoring from a nerve-wracking gamble into routine work, and it is the same reason I value fast deploy and rollback loops everywhere, as in owning your deploy pipeline end to end. Short feedback loops make aggressive change safe.
The characterization tests also become a permanent asset. They outlive the refactor. The code that had zero tests now has a real behavioral suite, which lowers the cost of every future change and, incidentally, raises the bus factor by documenting through tests what the code actually does.
The honest tradeoff
Writing characterization tests is not free, and for a heavily branched, untested module it can be more work than the refactor itself. That cost is real and it belongs in your estimate, which is exactly why I treat test coverage as a primary driver in how to estimate refactor effort before you start. Sometimes the characterization cost is high enough that the honest decision is to not refactor at all.
But when the refactor is worth doing, there is no safe shortcut around pinning the behavior first. Refactoring untested legacy code on faith is how you turn a cleanup into an outage. Characterize first, refactor second, fix bugs third, each step separable and each protected by tests that verify you changed the shape of the code without changing what it does.