Modernization

Refactoring Legacy Code Safely With Characterization Tests

Updated July 17, 2018By the CalliArc team

Key takeaway

Before changing untested legacy code, write tests that capture what it currently does — bugs included. Those characterization tests don't assert correctness; they detect change, which is exactly what you need to refactor with confidence.

The standard advice — write tests first — assumes you can. In a fifteen-year-old module with no tests, no documentation, and business rules nobody remembers, the practical question is how to make the first safe change.

Characterization tests: pin the behaviour first

Feed the code realistic inputs, record whatever it produces, and assert that it keeps producing exactly that. You are not asserting the behaviour is right — you're asserting it doesn't change while you restructure. If one of those captured outputs is a bug, you now have it documented, which is more than you had before, and you can decide deliberately whether to fix it.

  • Use real production-shaped inputs, including the strange ones.
  • Capture outputs and important side effects: database writes, messages sent, files produced.
  • Start at the coarsest level that's testable — an entire function or endpoint is fine.
  • Run them before you touch anything, so you know they pass on the current code.

Find a seam

  • A seam is a place where behaviour can be substituted without editing the code around it — a parameter, an injected dependency, a subclass.
  • Where none exists, the first refactoring is usually to create one: extract the untestable part (clock, filesystem, network) behind an interface.
  • Prefer changes your tooling can verify — automated extract-method and rename are far safer than hand-editing.

Work in small, reversible steps

  • One behaviour-preserving change at a time, with the tests run after each.
  • Commit after every green step; a long refactoring branch is exactly the risk you're trying to avoid.
  • Never mix refactoring with behaviour change in the same commit. When something breaks, you need to know which kind of change caused it.
  • Stop when the code is good enough to make the change you actually came to make. Perfecting a module you're not going to touch again is not the goal.

Choose where to spend the effort

Use the commit history: the files that change most often and are hardest to change are where this pays back. Code that hasn't been touched in five years may be ugly and is costing you nothing. And where a module is genuinely beyond repair, wrap it behind an interface and replace it incrementally rather than continuing to refactor something that should be retired.

Share LinkedIn X

Ready to build it right?

Get a transparent, milestone-based estimate for your project in a free consultation.

Book a free strategy call