Testing Legacy & Untested Code

Software Testing Strategy

Chapter 9 · Testing Legacy & Untested Code

Clean Code, SOLID & Refactoring's own capstone refactored TangleMart's tangled order processor in six verified, dependency-ordered steps — but it never had to answer a harder question: what do you do when the code you need to touch has no tests at all yet? Michael Feathers, in Working Effectively with Legacy Code, gives legacy code a precise, testable definition: code without tests, regardless of its age or who wrote it. This chapter builds the technique for getting a safety net under code like that before changing a single line.

Discover, Don't Guess

# TangleMart's legacy shipping calculator - ~5 years old, untested, # nobody currently on the team wrote it def legacy_calculate_shipping(weight, zone, express): if weight < 0: weight = 0 base = weight * 0.5 if zone == 'A': base *= 1.0 elif zone == 'B': base *= 1.2 elif zone == 'C': base *= 1.5 else: base *= 2.0 # unknown zone - undocumented, unclear if intentional if express: base += 10 if weight > 50: base *= 0.9 # undocumented bulk discount return round(base, 2)
Verified directly — running the code surfaces real behavior a reasonable guess would have missed
Probing the function with real inputs rather than reasoning about what it "should" do: (10, 'A', False) → 5.0, (10, 'B', False) → 6.0, (10, 'C', True) → 17.5, (-5, 'A', False) → 0.0 (negative weight silently clamped), (10, 'Z', False) → 10.0 — an unrecognized zone is charged more, not rejected as an error — and (60, 'A', False) → 27.0, reflecting an undocumented bulk discount past 50 units.
This is Feathers' own point, made concrete
None of those five behaviors are documented anywhere in the function. Some might be intentional business rules; some might be genuine bugs nobody's ever noticed because the inputs that trigger them are rare. A characterization test doesn't take a position on which — it captures exactly what the code does right now, so any future change to that behavior becomes a deliberate, visible decision instead of an accident.

Characterization Tests as a Safety Net

def test_char_unknown_zone_defaults_expensive(): assert legacy_calculate_shipping(10, 'Z', False) == 10.0 # captured as-is, not "fixed"
Verified directly — all 6 characterization tests passed against the original code, unmodified
Six tests, one per discovered behavior above, all asserting the exact values found by running the code — not values reasoned out independently. All 6 pass, by construction: a characterization test can never fail against the code it was captured from, since it simply records what that code already does.

A Safe Refactor, Verified Against the Same Net

ZONE_MULTIPLIERS = {'A': 1.0, 'B': 1.2, 'C': 1.5} def calculate_shipping_refactored(weight, zone, express): weight = max(weight, 0) multiplier = ZONE_MULTIPLIERS.get(zone, 2.0) # unknown zone: preserved exactly as 2.0 base = weight * 0.5 * multiplier if express: base += 10 if weight > 50: base *= 0.9 return round(base, 2)
Verified directly — the refactored version reproduced every discovered value exactly
All 6 characterization tests, run against calculate_shipping_refactored instead of the original: all 6 pass, including the exact same undocumented quirks — the negative-weight clamp, the unknown-zone default of 2.0, and the bulk discount. A genuinely cleaner implementation (dict lookup, no repeated if/elif), zero behavior change.

The Real Payoff: Catching an Accidental Change

# a well-meaning "fix" - someone assumes the unknown-zone multiplier # should default to 1.0, not the odd-looking 2.0 multiplier = ZONE_MULTIPLIERS.get(zone, 1.0) # changed from 2.0
Verified directly — the characterization test caught the unintended change immediately
calculate_shipping_WELLMEANING_FIX(10, 'Z', False) now returns 5.0 instead of the captured 10.0the characterization test fails. Whether 2.0 was a real bug or an intentional (if undocumented) business decision is still unknown — but the test forces that question to be asked and answered deliberately, instead of letting the change ship silently as a side effect of an unrelated refactor.
A characterization test protects against accidental change — it doesn't bless the behavior as correct
If the team decides 2.0 really is a bug, the fix is the same one shown above — but now it's a deliberate, reviewed change to the test's own expected value (exactly Chapter 8's own "update the scenario intentionally" pattern), not a silent side effect nobody noticed until a customer complained.

Where This Connects

This chapter's findingWhat it connects to
Capturing real behavior before refactoring, verified surviving a safe refactorClean Code, SOLID & Refactoring's own capstone — the missing first step for code that starts with zero tests
A test failing on an unintended change, forcing a deliberate decisionChapter 8's own "update the scenario intentionally" pattern — the same discipline, applied to undocumented legacy behavior instead of a known business rule

Hands-On Exercises

Exercise 1

Probe this chapter's own legacy_calculate_shipping with a new input it wasn't tested against: (0, 'B', True) (zero weight, express). Discover the actual returned value by running the code, write a characterization test asserting it, and verify it passes against both the original and refactored versions.

📄 View solution
Exercise 2

This chapter's own bulk discount triggers at weight > 50. Discover what happens at exactly weight = 50 (the boundary itself) by running the code, and write a characterization test capturing that exact boundary behavior.

📄 View solution
Exercise 3

Introduce a second unintended change to this chapter's own refactored version: accidentally apply the bulk discount at weight >= 50 instead of weight > 50. Determine which of this chapter's own 6 characterization tests (if any) catches it, and explain why the answer depends on which specific inputs happen to already be covered.

📄 View solution

Chapter 9 Quick Reference

  • Feathers' definition: legacy code is simply code without tests — age and authorship don't matter
  • Characterization tests: capture what the code actually does, discovered by running it — never what you think it should do
  • Verified: 6 characterization tests captured a legacy function's real behavior, including 3 undocumented quirks (negative-weight clamping, an expensive unknown-zone default, a bulk discount)
  • Verified: all 6 tests passed unchanged against a genuinely cleaner refactor, proving behavior was preserved exactly
  • Verified: the same 6 tests caught a well-meaning "fix" that silently changed undocumented behavior — forcing a deliberate decision instead of an accidental one
  • Next chapter: Capstone — designing a full test strategy for a real system, from the ground up