Claude Cursor GitHub Copilot Skill

working-with-legacy-code

Safely change and test untested codebases using Feathers' "Working Effectively with Legacy Code". Use when the user mentions "legacy code", "no tests", "untested codebase", "how do I test this", "seams", "characterization tests", "golden master", "sprout method", "afraid to chang

LLM Mart · 0 points · 12 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download wondelai-skills-working-with-legacy-code-eade5d1.zip · 33 KB
Part of wondelai/skills — 183 skills

Install

skills CLI npx skills add https://github.com/wondelai/skills/tree/main/working-with-legacy-code
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wondelai-skills@llmmart
Git git clone https://github.com/wondelai/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wondelai/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Working Effectively with Legacy Code

A field manual for changing code that has no tests, distilled from Michael C. Feathers' Working Effectively with Legacy Code. Use it to get untestable classes into a harness, pin down current behavior with characterization tests, and make changes one safe, verifiable step at a time — without resorting to a rewrite.

Core Principle

Legacy code is simply code without tests. Not old code, not ugly code — untested code: without tests you cannot know whether a change preserves behavior, so every edit is a gamble. The craft is breaking dependencies just enough to get tests in place before changing anything — cover and modify, never edit and pray.

Scoring

Goal: 10/10. Rate changes to untested code 0-10 against the principles below. Report the current score and the specific steps needed to reach 10/10.

  • 9-10: Change points covered by characterization tests before any edit; behavior changes and refactoring shipped as separate verified steps; dependencies broken with the least invasive technique
  • 7-8: Tests at most change points, but occasional mixed refactor-plus-behavior commits or heavier dependency surgery than needed
  • 5-6: Some characterization tests, yet key paths still changed on faith; sprouted code accumulating with no payback plan
  • 3-4: Edit-and-pray with manual verification; tests written after the change, asserting whatever the new code happens to do
  • 0-2: Untested edits straight into tangled code, refactoring and behavior change mixed in one commit, rewrite proposed instead of tests

Framework

1. The Legacy Code Dilemma and Change Algorithm

Core concept: The dilemma: to change code safely we need tests, but to get tests in place we have to change code. The way out is a fixed sequence — identify change points, find test points, break dependencies, write tests, then make changes and refactor — where the pre-test edits are conservative and mechanical, and the real change happens only inside the safety net.

Why it works: Edit-and-pray substitutes care for feedback, and care doesn't scale to code you don't fully understand. Cover-and-modify clamps existing behavior in a vise of tests, so any unintended change announces itself immediately on your machine instead of later in production.

Key insights:

  • There are two reasons to change code — changing behavior (feature, bug fix) and improving structure (refactoring) — and mixing them in one step makes failures undiagnosable
  • Test points are rarely the change points: effects propagate, so you often test where the change's effects surface, not where the edit happens
  • Dependency-breaking edits made before tests exist must preserve signatures exactly and lean on the compiler to find every affected site
  • Coverage grows along the paths you actually change — that beats any dedicated "testing project" that never gets funded
  • "Programming is the art of doing one thing at a time": each step of the algorithm is separately verifiable

Applications:

Context Application Example
Bug fix in an untested module Run the five steps before touching the bug Pin parseInvoice() with tests, then fix the rounding error
PR mixing cleanup and a feature Split into structure-only and behavior-only commits Extract and rename first, tests green, then add the discount rule
"It's just a one-line change" Find the nearest test point first One pin test at the public method that calls the private one you edit

See references/change-algorithm.md when running the five steps on a real change — the algorithm as a working procedure with change-point/test-point checklists and triage for "no time" situations.

2. Seams: Where to Pry Code Apart

Core concept: A seam is a place where you can alter behavior in your program without editing in that place. Every seam has an enabling point — where you decide which behavior runs. Getting legacy code under test is largely a hunt for seams: spots where a test can substitute a slow, global, or external dependency while the production source stays untouched.

Why it works: If you must edit code to test it, you risk changing the very behavior you are trying to pin down. Seams move the substitution to a distance — a subclass, an import, a build flag — so the code under test runs exactly as in production while the test controls its dependencies from the enabling point.

Key insights:

  • Object seams are the default in OO code: every overridable call is a seam, and its enabling point is wherever the object is created or passed in
  • Link and import seams swap implementations at build or load time — jest.mock and unittest.mock.patch are link seams in modern clothing
  • Preprocessing seams (C/C++ macros) are the bluntest instrument; reach for them last
  • new Database() inside a method body is a seam that never got built — constructors doing real work, globals, statics, and hard-wired I/O are where seams die
  • A seam without a reachable enabling point is useless: if the test can't make the decision, keep hunting
  • Dynamic languages make nearly every name lookup a seam — cheap, but patching internals couples tests to file layout

Applications:

Context Application Example
Class constructs its own DB client Object seam via constructor parameter constructor(db: Db = new ProdDb()) — tests pass a fake
Module calls a top-level send_email() Import/link seam mocker.patch("billing.send_email") or jest.mock("./mailer")
Logic reads the wall clock directly Seam at the clock Inject a now() provider; tests freeze time

See references/seams.md when hunting a seam in a specific stack — the seam catalog with code, enabling points, and seams in modern tooling (DI containers, jest.mock, pytest monkeypatch, clock and config seams).

3. Characterization Tests

Core concept: A characterization test documents what the code actually does right now — not what the spec, the comments, or anyone's memory says it should do. Write a probe you know will fail, let the failure message reveal the real behavior, then change the assertion to pin that behavior in place.

Why it works: In legacy systems the actual behavior is the de facto spec: callers, reports, and customers may depend on it, quirks included. Tests written from imagined requirements fail for reasons that tell you nothing, while characterization tests fail during refactoring precisely when — and where — you changed existing behavior.

Key insights:

  • The recipe: call the code in a harness, assert something absurd (expect(total).toBe(-1)), read the failure, pin the observed value
  • Sensing and separation are the two reasons to break dependencies: separation gets code into a harness, sensing lets assertions see what it computed
  • For complex output (reports, generated files, large JSON) use a golden master: capture the full output once, diff against it forever
  • Snapshot tests are golden masters — review the first snapshot like code and normalize volatile data, or you are pinning noise
  • Found a bug while characterizing? Pin it with a comment and a ticket — downstream code may depend on the wrong behavior; fix it later as a deliberate, separate change
  • Characterize the branches your change will touch, not the whole system — coverage follows change

Applications:

Context Application Example
Refactoring a tax calculator Pin outputs for representative inputs Run 20 cases through, assert each recorded result
Legacy report generator Golden master diff Generate the report, compare to a checked-in master file
Off-by-one found while pinning Pin the wrong value, document it assert days == 30 # BUG? expected 31 — TICKET-482

See references/characterization-tests.md when writing your first probe through to a pinned suite — golden masters, snapshot tests done right, and a worked before/after refactor.

4. Sprout and Wrap: Changing Without Tests First

Core concept: When you genuinely cannot get the area under test today, don't weave new logic into the untested mass. Sprout Method or Sprout Class: write the new behavior as fresh, fully tested code and call it from a single line in the legacy spot. Wrap Method or Wrap Class: rename the old code aside and add behavior before or after the call to it, decorator-style.

Why it works: New code in a fresh method or class can be test-driven even when its host can't be instantiated in a harness — testability no longer waits on getting the host into a harness. The untested host changes by exactly one call site, so the unverified blast radius is a single line instead of the whole method.

Key insights:

  • Sprout Method when new logic plugs in at one point; Sprout Class when the host class won't even instantiate in a test harness
  • Wrap Method suits behavior that surrounds the old code (logging, notification, metering) rather than mixes with it: rename pay() to rawPay(), recreate pay() as the wrapper
  • Wrap Class is the Decorator pattern — use it when several call sites need the added behavior or the class is already bloated
  • Be honest about the trade-off: the host stays untested; you have added good code to a bad neighborhood
  • Track sprouts as debt and pay them back — cover the host the next time a change lands there
  • Sprouting is a tactical move inside the change algorithm, not a permanent substitute for getting code under test

Applications:

Context Application Example
Late-fee rule in a 400-line process() Sprout Method, one call line total += lateFee(order) — lateFee() written test-first
Audit logging around legacy pay() Wrap Method New pay() logs, calls rawPay(), logs again
New validation, class won't instantiate Sprout Class new OrderValidator().validate(data) called from legacy code

5. Dependency-Breaking Techniques

Core concept: A catalog of mechanical, low-risk moves that sever whatever blocks instantiation or sensing: Extract Interface, Parameterize Constructor, Parameterize Method, Extract and Override Factory Method or Getter, Introduce Instance Delegator, Adapt Parameter, Break Out Method Object, Subclass and Override Method. Because they run before tests exist, always pick the least invasive technique that unblocks you.

Why it works: Code resists testing for a small set of recurring reasons — constructors doing real work, statics and singletons, parameters you can't construct, monster methods. Each blocker has a named, practiced counter-move, so you execute a known maneuver instead of improvising surgery on code that has no safety net.

Key insights:

  • Parameterize Constructor with a production default is the workhorse: existing callers compile untouched while tests inject fakes
  • Extract Interface is the safest move in the book — introducing an interface can't change behavior, only loosen a type
  • Subclass and Override Method underlies half the catalog: a testing subclass that stubs the dangerous parts is a legitimate tool, not a hack
  • For statics and singletons, Introduce Instance Delegator hands callers an instance they can swap; a static setter can supersede a singleton in tests
  • Adapt Parameter beats fighting unfakeable framework types — wrap HttpServletRequest in your own narrow interface and test against that
  • Dynamic languages have cheaper seams: unittest.mock.patch or jest.mock can stand in for several techniques, but parameterizing leaves better design behind

Applications:

Context Application Example
Constructor opens a DB connection Parameterize Constructor def __init__(self, conn=None): self.conn = conn or connect()
Static Billing.charge() called everywhere Introduce Instance Delegator Instance charge() delegates to the static; tests override it
900-line method hoarding locals Break Out Method Object new RateCalculation(order, rates).run() — locals become fields

See references/dependency-breaking.md when a specific blocker stops instantiation or sensing — before/after code for each technique plus a decision table mapping blockers to the right move.

6. Untangling and Understanding

Core concept: Before changing code you don't understand, invest in cheap comprehension: effect sketches trace what a change can affect, feature sketches show how methods and fields cluster inside a god class, scratch refactoring means refactoring recklessly to learn and then throwing the edits away, and telling the story of the system forces a simplifying summary. The payoff is finding pinch points — narrow places where a few tests cover wide behavior.

Why it works: In legacy code the bottleneck is comprehension, not typing. An effect sketch turns "what could this break?" from anxiety into a finite list, and a pinch point lets a handful of tests act as a vise over an entire cluster of methods — often revealing where a hidden class boundary wants to be drawn.

Key insights:

  • Effect sketch: a bubble per variable or method, an arrow per "affects" — trace forward from your change point to every place behavior can leak out
  • A pinch point is a narrowing in the effect sketch; test there and everything upstream of it is covered
  • Scratch refactoring is refactoring as a reading technique: extract, rename, and simplify for an hour, then revert — the insight survives the checkout
  • Monster method strategy: golden-master it at a pinch point, Break Out Method Object, then refactor inside the new class
  • God class strategy: feature-sketch the clusters, then extract along the natural boundaries between them
  • Triage when there's no time: a spot changing once gets a sprout or wrap; the same spot changing again has earned its tests

Applications:

Context Application Example
"What breaks if I change this field?" Effect sketch from the field outward Three readers found; two pinch-point tests cover them
Feature due in a 5,000-line class Pinch-point tests, then sprout Cover postInvoice(), sprout the new rule as a class
Code nobody on the team understands Scratch refactor on a branch Extract and rename to learn, revert, plan the real moves

See references/case-studies.md when you want a full worked walkthrough — three scenarios: a feature in an untested 800-line service, a singleton-ridden module brought under test, and a monster method tamed before a bug fix.

Common Mistakes

Mistake Why It Fails Fix
Refactoring and changing behavior in one step When something breaks, you can't tell which edit did it Separate commits; tests green between each step
Writing "should" tests on legacy code Imagined specs fail noisily and you "fix" load-bearing behavior Characterize what the code does; file bugs separately
Mocking everything in sight Tests pin the implementation, so every refactor breaks them Fake only what blocks instantiation or sensing
Big-bang rewrite instead of incremental coverage The old system keeps moving; rewrites ship late and miss years of edge cases Cover and modify piece by piece
Silently fixing bugs found while characterizing Callers and reports may depend on the wrong behavior Pin it, document it, fix it as a separate deliberate change
Invasive cleanup before any tests exist Every manual edit risks behavior with no net underneath Least invasive technique; preserve signatures; lean on the compiler
Sprouting forever without payback The host stays untested and sprouts ossify into the next legacy layer Track sprout debt; cover hot spots on the next touch
Waiting for a dedicated "testing project" That project never gets funded; coverage never appears Grow coverage along every change you ship

Quick Diagnostic

Question If No Action
Do tests cover the code you're about to change? You're editing and praying Run the change algorithm; pin behavior before editing
Can you construct the class in a test harness? Dependencies block separation Parameterize Constructor, Extract Interface, or Sprout Class
Can a test sense the effect of your change? Effects are invisible to assertions Find a sensing point; Extract and Override Getter
Is this commit behavior-only or structure-only? Mixed Split it; run the tests between the two
Do you know everything this change can affect? Unknown blast radius Draw an effect sketch; test at the pinch points
Do your assertions state observed behavior? Testing wishes Probe, read the failure, pin the actual value
Is the seam you chose the cheapest one available? Needless surgery Prefer constructor parameters and import seams first
Will the code be better covered after this change? The next change costs as much as this one Leave at least one pin test at the nearest test point

Further Reading

About the Author

Michael C. Feathers is the founder of R7K Research & Conveyance, a consultancy focused on software design and the rehabilitation of aging systems. A long-time consultant and conference speaker on legacy code, he wrote Working Effectively with Legacy Code (2004) and gave the field its working definition: legacy code is simply code without tests.

Files (skills)
  • references
    • case-studies.md 13.5 KB
      # Case Studies: Legacy Code Techniques in Practice
      
      ## Table of Contents
      
      - [Case Study 1: Adding a Feature to an Untested 800-Line Service Class](#case-study-1-adding-a-feature-to-an-untested-800-line-service-class)
      - [Case Study 2: Getting a Singleton-Ridden Module Under Test](#case-study-2-getting-a-singleton-ridden-module-under-test)
      - [Case Study 3: Taming a Monster Method Before a Bug Fix](#case-study-3-taming-a-monster-method-before-a-bug-fix)
      - [Key Takeaways](#key-takeaways)
      
      ## Case Study 1: Adding a Feature to an Untested 800-Line Service Class
      
      ### Context
      
      A six-year-old e-commerce backend in TypeScript. `OrderService` is 800 lines and handles order creation, payment capture, stock reservation, confirmation emails, and refunds. It has zero tests. The new requirement: a loyalty points program — one point per 10 EUR on paid orders, points redeemable as a discount at checkout.
      
      The team's previous attempt to "just add" a small feature to this class caused a refund regression that took down order processing for an afternoon. Since then, nobody volunteers to touch the file.
      
      ### The Problems
      
      - The constructor connects to Postgres and Redis and instantiates a `StripeClient` — `new OrderService()` in a test harness opens real connections or crashes
      - `placeOrder()` is 190 lines and is the change point; its effects scatter across DB writes, a stock decrement, an email, and the returned `OrderResult`
      - Emails go through `Mailer.getInstance()`, a singleton that talks to SMTP from anywhere
      
      ### Step by Step
      
      **1. Identify change points.** Points are awarded when an order transitions to `paid` — inside `placeOrder()`, after payment capture. Redemption applies in `applyDiscounts()`. Two change points, both inside the scary file.
      
      **2. Find test points.** `placeOrder()` returns an `OrderResult` and calls the gateway and repositories — sensing is possible if those collaborators can be substituted. It is also a pinch point: payment, stock, and email logic all funnel through it, so tests here cover wide behavior cheaply.
      
      **3. Break dependencies — three commits, each provably behavior-free.**
      
      Commit 1, Parameterize Constructor with production defaults so no caller changes:
      
      ```typescript
      constructor(
        private db: OrderRepo = new PgOrderRepo(pool),
        private stock: StockRepo = new RedisStockRepo(redis),
        private gateway: PaymentGateway = new StripeClient(env.STRIPE_KEY),
        private mailer: ReceiptSender = Mailer.getInstance(),
      ) {}
      ```
      
      Commit 2, Extract Interface on the gateway — `PaymentGateway` declares the three methods `OrderService` actually calls, not Stripe's full surface.
      
      Commit 3, replace direct `Mailer.getInstance()` calls inside methods with the injected `this.mailer`. A grep confirms every send site routes through the field.
      
      **4. Write characterization tests.** Nine pin tests against `placeOrder()`: happy path, declined card, out-of-stock, coupon applied, free order, three quantity boundaries, malformed address. One genuine surprise: on a declined card, stock is decremented and *never restored*. The team pins it as-is with a `BUG?` comment and files a ticket — fixing it now would be an unreviewed behavior change hiding inside the safety-net step.
      
      **5. Make the change.** The loyalty logic is not woven into `placeOrder()`'s 190 lines. It is a Sprout Class — `LoyaltyLedger`, written test-first in isolation with seven specification tests — and the legacy file gains exactly two lines:
      
      ```typescript
      // in placeOrder(), after capture succeeds:
      await this.loyalty.award(order.customerId, pointsFor(order.totalCents));
      
      // in applyDiscounts():
      discount += await this.loyalty.redeem(order.customerId, requestedPoints);
      ```
      
      ### Outcome
      
      | Measure | Before | After |
      |---------|--------|-------|
      | Tests touching `OrderService` paths | 0 | 16 (9 pins + 7 loyalty specs) |
      | Time spent | — | 2.5 days (vs. 1 day estimated for "just add it") |
      | Regressions shipped | the historical norm | 0 |
      | Known stock bug | invisible | pinned, ticketed, fixed deliberately the next sprint |
      | Next feature in the same area | feared | shipped in half a day on top of the pins |
      
      ### Lessons Learned
      
      1. **The fear was a dependency problem, not a complexity problem.** Three mechanical commits made an "untouchable" class testable; the 800 lines were never the real obstacle.
      2. **The sprout kept the new logic clean.** `LoyaltyLedger` was born fully tested and never inherited the host's mess; the host gained call lines, not branches.
      3. **The pinned bug paid for the whole exercise.** Edit-and-pray would have either shipped past the stock bug again or "fixed" it silently and broken the warehouse reconciliation that had quietly compensated for it.
      
      ## Case Study 2: Getting a Singleton-Ridden Module Under Test
      
      ### Context
      
      A Python/Flask pricing service, eight years old. `pricing.py` computes quotes and is due for a VAT rule change with a legal deadline. Functions reach for `Config.instance()` and `FeatureFlags.instance()` at call time, and the module constructs its database client at import time:
      
      ```python
      # pricing.py (top of file)
      db = Database(Config.instance().dsn)   # runs on import
      
      def quote_price(items, country):
          cfg = Config.instance()
          flags = FeatureFlags.instance()
          rate = db.vat_rate(country)
          ...
      ```
      
      Importing `pricing` in a test process connects to the production replica. There are no tests, and the team has been verifying pricing changes by deploying to staging and eyeballing quotes.
      
      ### The Problems
      
      - Import-time side effects: the module cannot even be loaded in a harness safely
      - Singletons are read deep inside functions — no parameters to substitute, no visible seams
      - Quote logic branches on feature flags, dates, and country tables, so manual verification misses combinations
      
      ### Step by Step
      
      **1. Kill the import-time work first.** Wrap the module-level client in a lazy accessor — a mechanical, signature-shaped change:
      
      ```python
      _db = None
      
      def get_db():
          global _db
          if _db is None:
              _db = Database(Config.instance().dsn)
          return _db
      ```
      
      Every `db.` becomes `get_db().` — a find-and-replace verified by grep, committed as "no behavior change." The module now imports cleanly; nothing connects until first use.
      
      **2. Introduce Static Setter on the singletons** for test control, with hygiene built in:
      
      ```python
      class Config:
          _instance = None
      
          @classmethod
          def instance(cls):
              if cls._instance is None:
                  cls._instance = cls._load()
              return cls._instance
      
          @classmethod
          def set_instance(cls, fake):
              cls._instance = fake
      ```
      
      ```python
      # conftest.py
      @pytest.fixture(autouse=True)
      def reset_singletons():
          yield
          Config.set_instance(None)
          FeatureFlags.set_instance(None)
      ```
      
      The autouse reset matters as much as the setter: without it, one test's fake config bleeds into the next, and test order starts to matter.
      
      **3. Parameterize the function under change.** The VAT work lands in `quote_price`, so that function gets real seams while the rest of the module keeps its singletons for now:
      
      ```python
      def quote_price(items, country, *, config=None, flags=None, db=None):
          config = config or Config.instance()
          flags = flags or FeatureFlags.instance()
          db = db or get_db()
          ...
      ```
      
      Production callers are untouched; tests pass plain fakes with no patching.
      
      **4. Characterize.** Fourteen pin tests across countries, flag combinations, and date boundaries, with `freezegun` freezing the clock for date-dependent rates. One surprise pinned: an obsolete flag still silently zeroes VAT for one legacy country path — ticketed, not fixed.
      
      **5. Make the VAT change** as a behavior-only commit: two new assertions flipped from observed-old to specified-new values, implementation follows, everything else stays green.
      
      ### Outcome
      
      | Measure | Before | After |
      |---------|--------|-------|
      | Tests on `pricing.py` | 0 | 14 pins + 5 VAT specs |
      | Importing the module in tests | connects to prod replica | side-effect free |
      | `mock.patch` lines per test | n/a (no tests) | 0 — fakes passed as parameters |
      | VAT change verification | deploy to staging and eyeball | red-green on the pinned suite |
      | Regression from the VAT change | historically likely | none |
      
      ### Lessons Learned
      
      1. **In Python legacy, import-time side effects are the first enemy.** Nothing else is fixable until the module loads cleanly; the lazy accessor is a five-minute cure.
      2. **The static setter is scaffolding, not the destination.** It bought testability in an hour; the parameterized `quote_price` is the pattern the module migrates toward function by function.
      3. **Autouse resets make global-state seams survivable.** The fixture cost two lines and prevented the flaky, order-dependent suite that usually follows singleton setters.
      
      ## Case Study 3: Taming a Monster Method Before a Bug Fix
      
      ### Context
      
      A Java billing system generates customer statements. `generateStatement()` is roughly 700 lines: balance forward, payments, late-payment interest, taxes, formatting — about forty local variables, nesting nine levels deep. A confirmed bug: when a partial payment lands inside the grace period, late interest is applied twice. Finance wants the fix this week. The fix "looks like one line," but everyone who has touched this method has broken a different statement scenario.
      
      ### Step by Step
      
      **1. Find the test point — the method's return value is a gift.** `generateStatement()` returns the full statement as a `String`: ideal golden-master material. The team builds twelve input fixtures from anonymized production accounts covering the payment patterns that matter (on-time, late, partial-in-grace, multiple partials, zero balance, credit balance), captures the outputs, reviews them line by line, and checks them in as masters. Dates and statement IDs are normalized before comparison.
      
      **2. Break Out Method Object.** Extracting pieces directly from the method is hopeless — every fragment touches a dozen locals. Instead, the whole body moves verbatim into a new class where locals become fields:
      
      ```java
      class StatementRun {
          private final Account account;
          private final LocalDate asOf;
          private BigDecimal balance;      // was a local
          private BigDecimal interest;     // was a local
          // ... the other forty, now fields
      
          StatementRun(Account account, LocalDate asOf) { ... }
      
          String run() {
              // 700 lines moved verbatim
          }
      }
      
      String generateStatement(Account account, LocalDate asOf) {
          return new StatementRun(account, asOf).run();
      }
      ```
      
      Masters rerun: all green. The move was pure structure.
      
      **3. Extract inside the new class, masters after every step.** With locals as fields, extraction is suddenly mechanical: `applyPayments()`, `interestFor(Period p)`, `renderLines()` — signatures copied exactly, one extraction per commit, golden masters green after each. Twenty minutes per move because verification is a test run, not an afternoon of manual statement-reading.
      
      **4. Reproduce the bug as a unit test.** `interestFor()` is now directly callable. The failing test takes ten minutes to write and fails for exactly the reported reason: the grace-period branch adds interest that the partial-payment branch has already accrued.
      
      **5. Fix the bug as a deliberate behavior change.** The fix is two lines, not one — the same double-application exists in a second branch for multi-payment months, which the unit test's sibling case exposes. Exactly one golden master diffs: the partial-in-grace fixture. The team reviews the diff hunk by hunk with finance, accepts the new master, and commits fix, flipped unit test, and regenerated master together.
      
      ### Outcome
      
      | Measure | Before | After |
      |---------|--------|-------|
      | Tests on statement generation | 0 | 12 golden masters + 6 unit tests on interest |
      | The "one-line" fix | one line, fingers crossed | two lines, second site caught by tests |
      | Verification of a statement change | manual reading of samples | seconds: rerun the masters |
      | `generateStatement()` | 700-line monolith | thin delegate over a structured `StatementRun` |
      | Time spent | — | 1.5 days, deadline met |
      
      ### Lessons Learned
      
      1. **Golden masters made a 700-line method testable in hours, without understanding all of it.** Comprehension followed coverage, not the other way around.
      2. **Break Out Method Object created the room to work.** The monster wasn't forty unrelated locals; it was three clusters, visible the moment they became fields.
      3. **The fix everyone called "one line" was two.** The second site is precisely the kind of thing edit-and-pray misses and a pinned suite catches by construction.
      
      ## Key Takeaways
      
      1. **Fear is a dependency problem.** In all three cases the team was afraid of files, but the actual obstacles were constructors, singletons, and missing seams — each removable with a named, mechanical technique.
      2. **Pin before you change, even when the pin records a bug.** Two of the three teams found real bugs while characterizing; both pinned first and fixed deliberately later, preserving behaviors other systems had silently grown to depend on.
      3. **New logic goes in new, tested code.** Sprouted classes and extracted methods were born clean; the legacy hosts gained call lines, not branches.
      4. **Scaffolding and destination are different things.** Testing subclasses, static setters, and golden masters bought tests *today*; parameterized constructors and adapted parameters are the design the code migrates toward.
      5. **Coverage arrives with change, not with permission.** None of these teams got a "testing sprint." They bought their safety nets inside ordinary feature and bug-fix work — which is the only budget that reliably exists.
      
    • change-algorithm.md 12 KB
      # The Legacy Code Change Algorithm
      
      The change algorithm is the spine of *Working Effectively with Legacy Code*: a fixed, repeatable procedure for changing code that has no tests. Improvisation is how the codebase got into this state; the algorithm replaces improvisation with five steps that are individually small, individually checkable, and always done in order:
      
      1. Identify change points
      2. Find test points
      3. Break dependencies
      4. Write tests
      5. Make changes and refactor
      
      ## Table of Contents
      
      - [The Dilemma](#the-dilemma)
      - [Edit and Pray vs. Cover and Modify](#edit-and-pray-vs-cover-and-modify)
      - [Two Kinds of Change, Never Mixed](#two-kinds-of-change-never-mixed)
      - [The Five Steps on a Running Example](#the-five-steps-on-a-running-example)
      - [The Safety Checklist](#the-safety-checklist)
      - [Triage: "I Don't Have Much Time and I Have to Change It"](#triage-i-dont-have-much-time-and-i-have-to-change-it)
      
      ## The Dilemma
      
      To change code safely, we need tests around it. To get tests around it, we usually have to change it — extract a parameter, loosen a type, route around a singleton. The legacy code dilemma is that the safety net requires the very kind of edit it is supposed to protect.
      
      The resolution is asymmetry. The edits you make *before* tests exist belong to a restricted class: mechanical, conservative, signature-preserving moves chosen from a known catalog (see the dependency-breaking reference). The edits you make *after* tests exist can be ambitious. You earn the right to interesting changes by making boring ones first.
      
      Two disciplines keep the pre-test edits safe:
      
      - **Preserve signatures.** When moving or extracting code without tests, copy method signatures exactly — cut and paste, never retype. Every keystroke you don't make is a bug you can't introduce.
      - **Lean on the compiler.** In typed languages, make a deliberate change (rename a variable, alter a constructor) and let the type errors enumerate every site that needs attention. The compiler becomes a crude but exhaustive impact analysis. In dynamic languages, lean on the import system and a fast grep instead — and trust them less.
      
      ## Edit and Pray vs. Cover and Modify
      
      *Edit and pray* is the industry default: study the code, make the change very carefully, click around the app, deploy, hope. The praying isn't a joke — it is the actual verification strategy. Care is a real input to quality, but care without feedback is just slower gambling: nothing in the process *tells you* whether the change preserved behavior.
      
      *Cover and modify* works with a net. Tests around the code act as a software vise: they clamp existing behavior in place so that when your change wiggles something you didn't intend, a test fails now, on your machine, pointing at the exact behavior that moved. The two styles also compound differently. Every edit-and-pray session leaves the code exactly as scary as it was. Every cover-and-modify session leaves a few tests behind, so the next change in that area starts cheaper.
      
      ## Two Kinds of Change, Never Mixed
      
      There are four reasons to change software — add a feature, fix a bug, improve the design, optimize resource usage — and they collapse into two categories:
      
      | Kind of change | What may change | What must be preserved |
      |----------------|-----------------|------------------------|
      | Behavior change (feature, bug fix) | One named, intended behavior | Every *other* behavior |
      | Structure change (refactoring, optimization) | Code structure, resource usage | *All* functional behavior |
      
      The discipline: never do both in the same step. A structure change starts green and ends green with no assertion edited. A behavior change updates or adds specific, named tests — and touches nothing else. When a mixed commit breaks something, you cannot tell whether the refactoring or the feature did it; when the steps are separate, the failing test plus the last commit is the whole diagnosis.
      
      Practically this means alternating commits: `refactor: extract PriceCalculator (no behavior change)` then `feat: prorate first-cycle charges`. Reviewers can hold the first kind to "tests unchanged and green" and the second to "exactly these assertions changed."
      
      ## The Five Steps on a Running Example
      
      The code below is typical legacy: useful, load-bearing, and hostile to tests.
      
      ```typescript
      // billing/subscription-biller.ts
      export class SubscriptionBiller {
        charge(sub: Subscription): Receipt {
          const gateway = new StripeGateway(process.env.STRIPE_KEY!);
          let amount = sub.plan.priceCents;
          if (sub.plan.interval === "year") {
            amount = Math.round(amount * (1 - sub.plan.annualDiscount));
          }
          if (sub.coupon) {
            amount -= this.couponValue(sub.coupon, amount);
          }
          amount += Math.round(amount * taxRateFor(sub.country));
          const result = gateway.charge(sub.customerId, amount);
          Mailer.getInstance().sendReceipt(sub.email, result);
          AuditLog.write(`charged ${sub.id}: ${amount}`);
          return new Receipt(sub.id, amount, result.transactionId);
        }
      }
      ```
      
      The task: subscriptions started mid-cycle must be prorated for their first charge.
      
      ### Step 1: Identify change points
      
      Where, exactly, will the edit live? Read until you can point at lines, not files. Here, proration affects the computation of `amount`, before tax — so the change point is the pricing block inside `charge()`. Identifying change points precisely matters because it determines *which behavior needs pinning*: everything that block currently does for existing subscriptions.
      
      If you can't locate the change point — the feature seems to live "everywhere" — stop and use the comprehension tools from the untangling section (effect sketches, scratch refactoring) before going further.
      
      ### Step 2: Find test points
      
      A test point is a place where you can write a test that detects the effects of your change. Change points and test points often differ: a private helper may be the change point while the public method that calls it is the only place its effects surface.
      
      List the effects of `charge()`: the returned `Receipt`, the call to the gateway, the email, the audit line. The return value and the gateway call are the high-value sensing targets. `charge()` itself is the natural test point — *if* we can construct a `SubscriptionBiller` and substitute the gateway.
      
      When many methods funnel through one place, that place is a **pinch point**: a narrowing in the effect graph where a few tests cover a lot of upstream behavior. Prefer pinch points when you must cover a wide area with a small test budget.
      
      ### Step 3: Break dependencies
      
      Three blockers stand between `charge()` and a harness: the hard-wired `StripeGateway` (network), the `Mailer` singleton (SMTP), and the static `AuditLog` (filesystem). The least invasive fix for the first two is Parameterize Constructor with production defaults:
      
      ```typescript
      export class SubscriptionBiller {
        constructor(
          private gateway: PaymentGateway = new StripeGateway(process.env.STRIPE_KEY!),
          private mailer: ReceiptSender = Mailer.getInstance(),
        ) {}
      
        charge(sub: Subscription): Receipt {
          let amount = sub.plan.priceCents;
          // ... pricing block unchanged ...
          const result = this.gateway.charge(sub.customerId, amount);
          this.mailer.sendReceipt(sub.email, result);
          AuditLog.write(`charged ${sub.id}: ${amount}`);
          return new Receipt(sub.id, amount, result.transactionId);
        }
      }
      ```
      
      Notes on conservatism: production call sites compile untouched because of the defaults; `PaymentGateway` is an interface extracted only as wide as this class needs (one method, not Stripe's forty); `AuditLog` is left alone for now — if it writes synchronously to disk, a test-side temp directory may be cheaper than more surgery. One technique, one commit, build green, move on.
      
      ### Step 4: Write tests
      
      Now pin current behavior with characterization tests (full procedure in the characterization reference). Cover the branches the change will touch — monthly, annual, coupon, a couple of tax countries:
      
      ```typescript
      test("monthly DE subscription pins at plan price plus 19% tax", () => {
        const gateway = new FakeGateway();
        const biller = new SubscriptionBiller(gateway, new NullMailer());
        const receipt = biller.charge(monthly({ priceCents: 3000, country: "DE" }));
        expect(receipt.amountCents).toBe(3570); // observed, not designed
        expect(gateway.charges).toEqual([{ customerId: "c_1", amountCents: 3570 }]);
      });
      ```
      
      The assertion records what the code *does*. If an observed value looks wrong, pin it anyway with a comment and a ticket — silently "fixing" it here would smuggle a behavior change into the safety-net step. Stop adding tests when every path through the pricing block is clamped.
      
      ### Step 5: Make changes and refactor
      
      With the vise closed, work normally. Test-drive the new behavior: write the failing proration test, implement minimally, go green. Then — as a separate, structure-only step — clean up: the pricing block now has three tangled rules, so extract a `PriceCalculator` while the characterization tests stay green. Two commits, two kinds of change, each verifiable on its own.
      
      ## The Safety Checklist
      
      **Before starting:**
      - Working tree clean; you can revert any single step
      - You can state in one sentence which behavior you intend to change — everything else must stay
      - You know which kind of change each upcoming step is (behavior or structure)
      
      **Before each dependency-breaking edit:**
      - Is this the least invasive technique that unblocks a test?
      - Are signatures preserved exactly — cut and paste, not retyped?
      - Can the compiler or type-checker enumerate every affected site for you?
      
      **After each dependency-breaking edit:**
      - Build green; any existing tests green
      - Production wiring provably unchanged (defaults still construct the real collaborators)
      - Committed separately with a message that says "no behavior change"
      
      **Before the behavior change:**
      - Characterization tests pin every branch the change touches
      - Each test would fail with a message that names the behavior that moved
      
      **After the behavior change:**
      - New behavior has its own intention-revealing tests
      - Structure cleanups follow as separate green-to-green commits
      - Any pinned bugs you decided to fix were flipped deliberately, assertion and fix in the same commit
      
      ## Triage: "I Don't Have Much Time and I Have to Change It"
      
      The honest answer from the book: getting code under test pays back sooner than you fear — often the same afternoon, when your first regression is caught before commit. But sometimes the deadline is real and the class needs a day of dependency-breaking you do not have. Choose deliberately rather than guiltily:
      
      | Situation | Move |
      |-----------|------|
      | Change is additive and plugs in at one point | Sprout Method: test-drive the new code, add one call line to the host |
      | Host class won't instantiate at all | Sprout Class: new behavior in a new, fully tested class |
      | Behavior surrounds the old code (logging, retry, notify) | Wrap Method or Wrap Class around the untouched original |
      | A test point is reachable with minutes of work | Write the one pin test anyway — cheaper than the postmortem |
      | No seams, no time, change forced inline | Pair on it, preserve signatures, single-goal editing — and file the debt ticket before you push |
      
      Two rules make triage safe instead of corrosive:
      
      1. **The second visit pays.** A spot that changes once can carry a sprout. The same spot changing again is the codebase telling you it is a hot path — get it under test on this visit, because you will be back.
      2. **Sprout debt is visible debt.** Mark every untested host you sprouted into with a grep-able comment (`// SPROUTED: host untested — TICKET-512`) and an actual ticket. Unmarked shortcuts read as endorsed style to the next developer; marked ones read as a queue.
      
      The algorithm looks slow on paper. In practice it is the fast path: the time sunk into dependency-breaking and pinning is bounded and front-loaded, while the time sunk into production regressions from edit-and-pray is unbounded and arrives at the worst moment. Teams that run the five steps stop being afraid of their own code — and fear, not the code, was the real bottleneck.
      
    • characterization-tests.md 11.8 KB
      # Characterization Tests
      
      A characterization test documents what the code actually does right now — not what the spec says, not what the comments claim, not what anyone remembers intending. On legacy code this is the only kind of test you can write honestly, because the actual behavior is the de facto specification: callers, cron jobs, spreadsheets, and customers may depend on it, quirks included. This file is the step-by-step practice: the first failing probe, choosing inputs, golden masters, snapshots, and a worked before/after refactor.
      
      ## Table of Contents
      
      - [What They Are (and Are Not)](#what-they-are-and-are-not)
      - [The Recipe](#the-recipe)
      - [Choosing Inputs](#choosing-inputs)
      - [Sensing and Separation](#sensing-and-separation)
      - [Golden Master Testing](#golden-master-testing)
      - [Snapshot Tests Done Right](#snapshot-tests-done-right)
      - [When the Current Behavior Is a Bug](#when-the-current-behavior-is-a-bug)
      - [Worked Example: Covering a Function Before Refactoring](#worked-example-covering-a-function-before-refactoring)
      
      ## What They Are (and Are Not)
      
      A specification test says *"this is what the code should do"* and fails when the implementation is wrong. A characterization test says *"this is what the code does"* and fails when the behavior *changes*. The distinction sounds philosophical and is intensely practical: writing "should" tests against legacy code produces a wall of red that tells you nothing except that your imagination and the codebase disagree — and worse, tempts you to "fix" discrepancies mid-characterization, silently changing behavior that something downstream relies on.
      
      Characterization tests have one job: clamp current behavior so you can refactor or extend with the vise closed. Some later get promoted into real specification tests with intention-revealing names; some get deleted once better tests exist. They are scaffolding, and scaffolding is allowed to be ugly as long as it holds.
      
      ## The Recipe
      
      1. Get the code into a harness (that is the separation problem — see the seams and dependency-breaking references).
      2. Write an assertion you *know* is wrong.
      3. Run it. Let the failure message tell you what the code actually does.
      4. Change the assertion to expect the observed value.
      5. Repeat until every behavior you are about to disturb is pinned.
      
      Step 2 is the part that feels illegal and isn't. You are not guessing the answer; you are asking the code a question, and the test runner is the conversation:
      
      ```python
      def test_probe_shipping_cost():
          cost = shipping_cost(weight_kg=0, country="PL")
          assert cost == -999  # absurd on purpose
      ```
      
      ```text
      E  assert 1500 == -999
      ```
      
      So a zero-weight parcel costs 15.00 — apparently there is a minimum fee. Now pin it, and let the test name record what you learned:
      
      ```python
      def test_zero_weight_parcel_charges_minimum_fee():
          assert shipping_cost(weight_kg=0, country="PL") == 1500  # observed minimum fee
      ```
      
      If you can't yet explain the observed value, pin it anyway under a neutral name (`test_characterize_shipping_zero_weight`) and a comment. An unexplained pinned value is still a tripwire; an unpinned one is a future regression.
      
      ## Choosing Inputs
      
      You are not characterizing the whole system — you are clamping the region your change will disturb. Heuristics, in order:
      
      - **Start from the change.** Read the code path your edit will touch and write one probe per branch on that path. The point of these tests is to detect *your* mistakes, so concentrate them where you are about to make some.
      - **Probe the boundaries.** Zero, negative, empty list, `None`/`null`, missing keys, maximum sizes, the day the clocks change. Legacy branches live at boundaries, and so do the bugs you must not silently fix.
      - **Use coverage as a flashlight, not a target.** Run the pinned suite under `coverage.py` or `jest --coverage` and look at what is still unexecuted *in the region you'll change*. A red line next to your change point is an unpinned behavior.
      - **Steal production values.** A handful of anonymized real records make better probes than invented ones — real data finds the branch you didn't see in the code.
      - **Stop at the vise.** When every branch you intend to disturb has a tripwire, stop. More pins now is procrastination with a green progress bar.
      
      ## Sensing and Separation
      
      We break dependencies for two reasons. **Separation**: we can't even get the code into a harness. **Sensing**: we can run it, but we can't see what it computed — results vanish into a database, a socket, a void return.
      
      Sensing options, from best to last resort:
      
      1. **Return values.** Free when they exist.
      2. **A recording fake.** Inject a collaborator that remembers calls:
      
         ```typescript
         class FakeGateway implements PaymentGateway {
           charges: Array<{ customerId: string; amountCents: number }> = [];
           charge(customerId: string, amountCents: number): ChargeResult {
             this.charges.push({ customerId, amountCents });
             return { transactionId: "t_1", ok: true };
           }
         }
         ```
      
         The test asserts on `gateway.charges` — the fake is both the separation and the sensor.
      3. **Extract and Override a getter** so a testing subclass can expose an intermediate value.
      4. **A sensing variable**: a temporary field that records an intermediate result inside a monster method (`this.lastComputedFee = fee`). Deliberately crude — add it, characterize, refactor, delete it.
      
      ## Golden Master Testing
      
      When output is large and structured — a rendered statement, generated XML, a 400-line report — per-value assertions are hopeless. Capture the entire output once, store it as the *golden master*, and diff against it forever:
      
      ```python
      from pathlib import Path
      
      def test_statement_matches_golden_master():
          out = generate_statement(load_fixture("acct_2231"))
          golden = Path("tests/golden/acct_2231.txt")
          if not golden.exists():
              golden.write_text(out)      # first run records the master
              raise AssertionError("Golden master recorded — review it, then rerun")
          assert out == golden.read_text()
      ```
      
      Rules that keep golden masters honest:
      
      - **Review the first capture like a code review.** Recording the master is the moment you sign off that *this* is the behavior to preserve. Read it line by line; you will usually find at least one surprise worth a ticket.
      - **Normalize volatility before comparing.** Timestamps, generated IDs, hostnames, float jitter — scrub them or every run cries wolf:
      
        ```python
        out = re.sub(r"\d{4}-\d{2}-\d{2}", "<DATE>", out)
        out = re.sub(r"stmt_[0-9a-f]{12}", "<STMT_ID>", out)
        ```
      
      - **Many small masters beat one big one.** One master per interesting input class (empty account, overdrawn, foreign currency) localizes failures; a single giant blob just says "something changed."
      - **Regenerating the master is a behavior change.** When a deliberate change diffs the master, review the diff hunk by hunk and commit the new master *with* the change that caused it. An auto-accepted master is no master at all.
      
      ## Snapshot Tests Done Right
      
      Jest-style snapshots are golden masters with tooling, and they fail in the same ways when treated casually:
      
      ```typescript
      test("invoice email renders for an overdue account", () => {
        expect(renderInvoiceEmail(overdueAccount())).toMatchSnapshot();
      });
      ```
      
      - Review the first snapshot as carefully as the code that produced it — committing an unread snapshot pins garbage with confidence.
      - Keep snapshots small and focused: one logical region per snapshot. Use `toMatchInlineSnapshot()` for short output so the expectation lives in the test, visible during review.
      - Normalize volatile fields with property matchers: `expect(obj).toMatchSnapshot({ createdAt: expect.any(Date) })`.
      - Treat `jest -u` as a behavior-change tool, not a "make CI green" button. A snapshot update in a PR deserves the same scrutiny as an assertion edit, because that is exactly what it is.
      
      ## When the Current Behavior Is a Bug
      
      You will find bugs while characterizing — it is one of the technique's reliable side effects. The discipline: **do not silently fix them.** That report someone reconciles monthly may compensate for the wrong number; that off-by-one may be cancelled by another off-by-one downstream. A silent fix during characterization is an unreviewed behavior change hiding inside a "no behavior change" step.
      
      The protocol:
      
      1. Pin the wrong behavior, loudly:
      
         ```python
         def test_grace_period_interest_currently_double_applied():
             total = late_interest(payments_fixture("partial_in_grace"))
             assert total == 2184  # BUG? interest applied twice — TICKET-482
         ```
      
      2. File the ticket with the failing scenario attached — the probe is a ready-made reproduction.
      3. Fix it later as a deliberate behavior change: flip the assertion and the code in the same commit, so the diff documents exactly which behavior moved and why.
      
      ## Worked Example: Covering a Function Before Refactoring
      
      The target — a pricing function with three tangled policies that we want to restructure:
      
      ```python
      def price_order(order, customer, today):
          total = 0
          for line in order.lines:
              price = line.qty * line.unit_cents
              if line.qty >= 10:
                  price = int(price * 0.95)        # bulk discount
              total += price
          if customer.tier == "gold" or (
              customer.since and (today - customer.since).days > 730
          ):
              total = int(total * 0.97)            # loyalty discount
          if order.coupon == "WELCOME" and not customer.orders:
              total -= 500                          # first-order coupon
          return max(total, 0)
      ```
      
      **Probe round.** Five probes, each asserting an absurd value, each failure recorded as a pin. Two surprises surface: the loyalty discount applies to customers with `since` exactly 731 days ago (boundary lives at `> 730`, not `>= 730`), and `WELCOME` plus loyalty *stack*, which the product owner thought was impossible. The second gets a ticket and a loud pin.
      
      **The pinned suite:**
      
      ```python
      def test_plain_order_sums_line_prices():
          assert price(order_of(qty=2, unit=1000)) == 2000
      
      def test_bulk_line_gets_five_percent_off_at_qty_10():
          assert price(order_of(qty=10, unit=1000)) == 9500
      
      def test_qty_9_gets_no_bulk_discount():
          assert price(order_of(qty=9, unit=1000)) == 9000
      
      def test_gold_customer_gets_three_percent_off_total():
          assert price(order_of(qty=2, unit=1000), customer=gold()) == 1940
      
      def test_loyalty_kicks_in_at_731_days_not_730():
          assert price(order_of(qty=2, unit=1000), customer=since_days(731)) == 1940
          assert price(order_of(qty=2, unit=1000), customer=since_days(730)) == 2000
      
      def test_welcome_coupon_stacks_with_loyalty():
          # BUG? stacking probably unintended — TICKET-519
          assert price(order_of(qty=2, unit=1000), customer=new_gold(), coupon="WELCOME") == 1440
      
      def test_total_clamps_at_zero():
          assert price(order_of(qty=1, unit=100), customer=new_customer(), coupon="WELCOME") == 0
      ```
      
      **The refactor, under green.** Now restructure with the vise closed:
      
      ```python
      def price_order(order, customer, today):
          subtotal = sum(line_price(line) for line in order.lines)
          discounted = loyalty_adjusted(subtotal, customer, today)
          return max(discounted - welcome_credit(order, customer), 0)
      
      def line_price(line):
          price = line.qty * line.unit_cents
          return int(price * 0.95) if line.qty >= 10 else price
      ```
      
      Run the suite after each extraction. Every test stays green, so behavior — including the stacking bug and the 731-day boundary — is preserved exactly. The structure change ships as its own commit. Next sprint, TICKET-519 flips one assertion and removes the stacking in a one-line behavior change that reviews in seconds, because the characterization suite proves nothing else moved.
      
      That is the rhythm to internalize: probe, pin, refactor under green, change behavior deliberately. The tests start as scaffolding around code nobody trusted; by the end, `line_price` and `loyalty_adjusted` are small enough to earn real specification tests, and the scaffolding can come down.
      
    • dependency-breaking.md 13.9 KB
      # Dependency-Breaking Techniques
      
      These techniques exist for one uncomfortable moment: you need to change code that has no tests, and the dependencies that block testing must be cut *before* the safety net exists. That constraint shapes everything. Every move here is mechanical and conservative: preserve signatures exactly (cut and paste, never retype), lean on the compiler to enumerate affected sites, apply one technique per commit, and prefer the least invasive option that unblocks a test. The goal is not good design — it is *tests*. Good design comes afterwards, under green.
      
      ## Table of Contents
      
      - [Decision Table](#decision-table)
      - [Extract Interface](#extract-interface)
      - [Parameterize Constructor](#parameterize-constructor)
      - [Parameterize Method](#parameterize-method)
      - [Extract and Override Factory Method](#extract-and-override-factory-method)
      - [Extract and Override Getter](#extract-and-override-getter)
      - [Subclass and Override Method](#subclass-and-override-method)
      - [Introduce Instance Delegator](#introduce-instance-delegator)
      - [Adapt Parameter](#adapt-parameter)
      - [Break Out Method Object](#break-out-method-object)
      - [Singletons: Introduce Static Setter](#singletons-introduce-static-setter)
      - [Language Notes](#language-notes)
      
      ## Decision Table
      
      | Blocker | Technique |
      |---------|-----------|
      | Constructor does real work (opens connections, reads files) | Parameterize Constructor |
      | Collaborator's type is concrete and heavy to instantiate | Extract Interface |
      | Method reads a clock, random source, or global inside its body | Parameterize Method |
      | Object creation buried inside a method you must test | Extract and Override Factory Method |
      | A field's value blocks testing but is used in many methods | Extract and Override Getter |
      | One dangerous call needs neutralizing in a test | Subclass and Override Method |
      | Static method or singleton called from everywhere | Introduce Instance Delegator / Introduce Static Setter |
      | Parameter type is impossible or painful to construct (framework object) | Adapt Parameter |
      | Method is hundreds of lines with tangled local variables | Break Out Method Object |
      
      ## Extract Interface
      
      The safest technique in the catalog: introducing an interface cannot change behavior — it only loosens a type so tests can substitute a fake.
      
      Before:
      
      ```typescript
      class ReportSender {
        constructor(private gateway: SmtpGateway) {}   // concrete, opens sockets
        sendDaily(report: Report): void {
          this.gateway.send(report.recipients, render(report));
        }
      }
      ```
      
      After:
      
      ```typescript
      interface MessageGateway {
        send(to: string[], body: string): void;        // only what ReportSender uses
      }
      
      class SmtpGateway implements MessageGateway { /* unchanged */ }
      
      class ReportSender {
        constructor(private gateway: MessageGateway) {}
        sendDaily(report: Report): void { /* unchanged */ }
      }
      ```
      
      Keep the interface exactly as wide as the class under test needs — one method extracted from SMTP's forty. A narrow interface is easy to fake, documents the real dependency, and avoids a maintenance contract with methods nobody calls. In TypeScript and Go, structural typing means existing fakes often satisfy the interface with no declaration at all; in Java and C#, your IDE's "extract interface" refactoring does this mechanically and safely.
      
      ## Parameterize Constructor
      
      The workhorse. The constructor builds its own dependencies; let callers supply them instead, with the old construction as a default so no caller changes.
      
      Before (Python):
      
      ```python
      class InvoiceSync:
          def __init__(self):
              self.db = PgConnection(os.environ["DSN"])   # connects on construction
              self.api = ErpClient(api_key=load_key())
      ```
      
      After:
      
      ```python
      class InvoiceSync:
          def __init__(self, db=None, api=None):
              self.db = db if db is not None else PgConnection(os.environ["DSN"])
              self.api = api if api is not None else ErpClient(api_key=load_key())
      ```
      
      Production call sites (`InvoiceSync()`) are untouched; tests write `InvoiceSync(db=FakeDb(), api=FakeErp())`. Use `None`-coalescing, not mutable defaults, and beware defaults that *evaluate* eagerly in languages where default expressions run at call time anyway (Python is fine; in TypeScript `constructor(db: Db = new ProdDb())` only constructs when the argument is omitted, which is the behavior you want). If the default construction is itself expensive to import, fall back to two constructors (Java overloads) or a static `create()` for production wiring.
      
      ## Parameterize Method
      
      Same move, method-scope. Hidden inputs read inside the body — the clock is the classic — become parameters with production defaults.
      
      Before:
      
      ```python
      def is_expired(self, token):
          return token.issued_at + self.ttl < datetime.now(timezone.utc)
      ```
      
      After:
      
      ```python
      def is_expired(self, token, now=None):
          now = now or datetime.now(timezone.utc)
          return token.issued_at + self.ttl < now
      ```
      
      Existing callers compile and behave identically; tests pass a fixed `now` and the time-boundary behavior becomes a plain assertion instead of a flake. The same pattern covers random seeds, environment lookups, and "who is the current user" reads.
      
      ## Extract and Override Factory Method
      
      When object creation is buried inside a method or constructor, move the `new` into a protected factory method, then override it in a testing subclass. Idiomatic in Java:
      
      ```java
      class TransactionLog {
          private final Db db;
      
          TransactionLog() {
              this.db = createDb();              // was: new OracleDb(Config.dsn())
          }
      
          protected Db createDb() {              // extracted factory method
              return new OracleDb(Config.dsn());
          }
      }
      
      class TestingTransactionLog extends TransactionLog {
          @Override
          protected Db createDb() {
              return new InMemoryDb();
          }
      }
      ```
      
      The body of the original method is unchanged except that `new` became `createDb()` — a signature-preserving, compiler-checked move. Cautions: calling overridable methods from constructors is fine in Java but a trap in C# and C++ (virtual dispatch during construction differs); in TypeScript, field initializers run before the subclass body, so prefer doing the creation lazily or passing through the constructor instead.
      
      ## Extract and Override Getter
      
      The same idea when a problematic field is used across many methods: route all access through a protected getter (often lazy), and override just the getter in tests.
      
      ```typescript
      class StatementJob {
        private _warehouse: WarehouseClient | null = null;
      
        protected warehouse(): WarehouseClient {
          if (!this._warehouse) this._warehouse = WarehouseClient.connect(env.WAREHOUSE_URL);
          return this._warehouse;
        }
      
        run(month: string): Summary {
          const rows = this.warehouse().query(monthQuery(month));  // every use goes through the getter
          return summarize(rows);
        }
      }
      
      class TestingStatementJob extends StatementJob {
        protected override warehouse(): WarehouseClient {
          return fakeWarehouse(rowsFixture());
        }
      }
      ```
      
      One override neutralizes every use of the dependency at once, and the lazy getter also stops construction-time side effects.
      
      ## Subclass and Override Method
      
      The umbrella move underneath both Extract-and-Override techniques: subclass the class under test and stub the dangerous parts.
      
      ```typescript
      class TestablePayrollRun extends PayrollRun {
        protected override postToLedger(entry: LedgerEntry): void {
          this.posted.push(entry);   // record instead of hitting the ledger service
        }
        posted: LedgerEntry[] = [];
      }
      ```
      
      A testing subclass is a legitimate tool, not a hack — it lives in the test tree, changes no production code, and is often the very first step that gets a class into a harness at all. Two rules of hygiene: override the *minimum* needed to separate and sense, and watch what the overrides are telling you. If the testing subclass stubs half the class, the class has at least two responsibilities and is asking to be split — under tests, later.
      
      ## Introduce Instance Delegator
      
      Static calls offer no seam: there is no instance to swap. Add instance methods that delegate to the static, then hand callers an instance they can replace.
      
      Before (Java):
      
      ```java
      class Billing {
          static Receipt charge(CustomerId id, Money amount) { /* talks to payment processor */ }
      }
      
      // caller, untestable:
      Receipt r = Billing.charge(order.customer(), total);
      ```
      
      After:
      
      ```java
      class Billing {
          static Receipt charge(CustomerId id, Money amount) { /* unchanged */ }
      
          Receipt chargeInstance(CustomerId id, Money amount) {   // delegator
              return charge(id, amount);
          }
      }
      
      // caller now holds a Billing instance (constructor-injected with a production default):
      Receipt r = billing.chargeInstance(order.customer(), total);
      ```
      
      Tests subclass `Billing` and override `chargeInstance`. Migrate callers gradually — each one you convert gains a seam; the rest keep working through the static. Once the static has no direct callers left, fold it into the instance method and retire the awkward name. The TypeScript equivalent is wrapping module-level functions in an injectable object: `const billing = { charge }` passed as a dependency.
      
      ## Adapt Parameter
      
      When the blocker is a *parameter type* you can't sensibly construct — framework request objects are the canonical case — don't fight the framework. Narrow the parameter to an interface you own.
      
      Before:
      
      ```typescript
      export function buildQuote(req: Request): Quote {       // Express Request: huge, stateful
        const age = Number(req.query.age);
        const zip = String(req.query.zip);
        /* 60 lines of actual pricing logic */
      }
      ```
      
      After:
      
      ```typescript
      export interface QuoteParams { age: number; zip: string }
      
      export function buildQuote(params: QuoteParams): Quote {
        /* the same 60 lines, now framework-free */
      }
      
      export const quoteHandler = (req: Request) =>
        buildQuote({ age: Number(req.query.age), zip: String(req.query.zip) });
      ```
      
      The logic is now testable with a plain object literal, and the adapter (`quoteHandler`) is so thin it barely needs testing. This is the Java `HttpServletRequest` cure as well: define the two-method interface your code actually reads, write one adapter, and stop faking forty-method servlet objects. Adapt Parameter is also the rare technique that *improves* the design on the spot — the new signature documents what the function really consumes.
      
      ## Break Out Method Object
      
      For monster methods: hundreds of lines, dozens of locals, impossible to extract from because every candidate fragment touches ten variables. Move the whole method into a new class where the locals become fields; then extraction becomes easy.
      
      Before (Python, abbreviated):
      
      ```python
      class Tariff:
          def rate_for(self, order, tariffs, today):
              # 400 lines: base rate, seasonal adjustments, surcharges,
              # caps, currency handling — all sharing ~20 locals
              ...
      ```
      
      After:
      
      ```python
      class RateCalculation:
          def __init__(self, order, tariffs, today):
              self.order = order
              self.tariffs = tariffs
              self.today = today
              self.base = 0
              self.surcharge = 0
      
          def run(self):
              self._base_rate()        # the 400 lines, moved verbatim,
              self._seasonal()         # then split into private methods —
              self._caps()             # trivial now that locals are fields
              return self._total()
      
      class Tariff:
          def rate_for(self, order, tariffs, today):
              return RateCalculation(order, tariffs, today).run()
      ```
      
      The initial move is verbatim — same statements, locals promoted to `self.` fields, signatures preserved. Run your golden master after the move, then again after each split. The new class is independently constructible, its pieces individually testable, and the hidden structure of the monster (those weren't twenty locals; they were three clusters) becomes visible in the field groupings.
      
      ## Singletons: Introduce Static Setter
      
      When `Config.instance()` is read deep inside everything, the fastest seam is a setter that supersedes the instance, plus a reset for test hygiene:
      
      ```python
      class Config:
          _instance = None
      
          @classmethod
          def instance(cls):
              if cls._instance is None:
                  cls._instance = cls._load_from_disk()
              return cls._instance
      
          @classmethod
          def set_instance(cls, fake):   # test-only seam
              cls._instance = fake
      ```
      
      Pair it with an autouse fixture that calls `Config.set_instance(None)` after each test, or state leaks between tests and order starts to matter. Be clear-eyed: the static setter is scaffolding. It admits global state rather than removing it. The destination is parameterized code that receives its config; the setter exists so you can write the tests that make that migration safe.
      
      ## Language Notes
      
      - **Java / C#:** the catalog as written. Lean hard on the compiler — every technique here is designed so that type errors enumerate the affected sites. Sealed/final classes and statics are the usual obstacles; `Introduce Instance Delegator` and wrapper interfaces handle most of them without bytecode-level mocking tools.
      - **TypeScript:** structural typing makes Extract Interface nearly free — any object with the right shape satisfies the type, so fakes need no declarations. Module mocking (`jest.mock`) can substitute for several techniques during characterization, but it couples tests to file paths; for code you will keep changing, parameterize anyway.
      - **Python:** `unittest.mock.patch` and monkeypatching can reach almost anything, which is exactly why restraint pays. Patching is fine for getting the first tests in place; parameterized seams (`def __init__(self, db=None)`) survive renames, document dependencies, and don't depend on import-order subtleties. Watch for import-time side effects — module-level construction defeats every technique here until you wrap it in a function.
      - **The general rule:** techniques that improve the design as a side effect (Parameterize Constructor, Adapt Parameter, Extract Interface) are permanent wins — leave them in. Techniques that merely enable tests (Subclass and Override, static setters, module patches) are scaffolding — schedule their retirement once real seams exist.
      
    • seams.md 10.5 KB
      # Seams: A Field Catalog
      
      A seam is a place where you can alter behavior in your program without editing in that place. Every seam has an **enabling point**: the place where you decide which behavior runs. Getting legacy code under test is mostly a hunt for seams — spots where a test can substitute a slow, global, or external dependency while the production source stays untouched. This file catalogs the seam types with code, then maps them onto modern stacks.
      
      ## Table of Contents
      
      - [Why Seams Matter](#why-seams-matter)
      - [Object Seams](#object-seams)
      - [Link and Import Seams](#link-and-import-seams)
      - [Preprocessing Seams](#preprocessing-seams)
      - [Enabling Points](#enabling-points)
      - [Seams in Modern Stacks](#seams-in-modern-stacks)
      - [Choosing the Cheapest Seam](#choosing-the-cheapest-seam)
      - [Traps](#traps)
      
      ## Why Seams Matter
      
      Characterization is only honest if the code under test is the code that runs in production. The moment you edit logic to make it testable, you are characterizing the edit, not the system. Seams resolve this: the substitution decision happens *somewhere else* — a subclass, a module registry, a build flag — and the function or class under test executes byte-for-byte as deployed. When a class has no seams at all, you don't reach for heavier mocking; you reach for the dependency-breaking catalog, whose whole purpose is to *create* seams with minimal, behavior-preserving edits.
      
      ## Object Seams
      
      The default seam in object-oriented code. Any call that can be overridden through polymorphism is an object seam:
      
      ```typescript
      class InvoiceMailer {
        send(invoice: Invoice): void {
          const body = this.render(invoice);
          this.deliver(invoice.customerEmail, body); // <- object seam
        }
      
        protected deliver(to: string, body: string): void {
          smtp.send(to, body); // the part we must not run in tests
        }
      
        protected render(invoice: Invoice): string {
          /* 80 lines of formatting we want to characterize */
        }
      }
      ```
      
      The call to `this.deliver(...)` is a seam because a subclass can change what it does without touching `send()` or `render()`:
      
      ```typescript
      class TestableInvoiceMailer extends InvoiceMailer {
        sent: Array<[string, string]> = [];
        protected override deliver(to: string, body: string): void {
          this.sent.push([to, body]); // record instead of sending
        }
      }
      
      test("renders overdue invoices with a late banner", () => {
        const mailer = new TestableInvoiceMailer();
        mailer.send(overdueInvoice());
        expect(mailer.sent[0][1]).toContain("OVERDUE");
      });
      ```
      
      Constructor injection is the same seam moved to the object's boundary — `new InvoiceMailer(transport)` makes the substitution explicit and reusable rather than requiring a subclass per test.
      
      The crucial negative: a call to `new StripeGateway()` or to a static method is **not** an object seam. There is no polymorphism to exploit; nothing a test can vary. Hard-wired construction, statics, and globals are precisely the places where object seams are missing, and techniques like Parameterize Constructor and Extract and Override Factory Method exist to install them.
      
      ## Link and Import Seams
      
      A link seam swaps an implementation at build, link, or load time, with zero edits to the code under test. The classic forms are linker substitution in C and classpath ordering in Java: point the build at a different object file or jar containing a fake, and every caller gets the fake.
      
      The modern, everyday form is module interception. In Jest, the module registry is the enabling point:
      
      ```typescript
      // billing.ts
      import { sendEmail } from "./mailer";
      
      export function closeAccount(id: string): void {
        const owner = repo.ownerOf(id);
        // ... teardown logic ...
        sendEmail(owner, "Your account is closed");
      }
      ```
      
      ```typescript
      // billing.test.ts
      jest.mock("./mailer"); // link seam: replaces the module before import resolution
      import { sendEmail } from "./mailer";
      import { closeAccount } from "./billing";
      
      test("closing an account notifies the owner", () => {
        closeAccount("a1");
        expect(sendEmail).toHaveBeenCalledWith("o1", "Your account is closed");
      });
      ```
      
      In Python the same seam is `unittest.mock.patch` (or pytest's `mocker` / `monkeypatch`), and the classic trap is patching the wrong name. Patch where the name is *used*, not where it is defined:
      
      ```python
      # billing.py
      from mailer import send_email
      
      def close_account(account_id):
          owner = repo.owner_of(account_id)
          send_email(owner, "Your account is closed")
      ```
      
      ```python
      def test_close_account_notifies_owner(mocker):
          fake = mocker.patch("billing.send_email")  # NOT "mailer.send_email"
          close_account("a1")
          fake.assert_called_once_with("o1", "Your account is closed")
      ```
      
      `billing.py` bound the name `send_email` into its own namespace at import time; patching `mailer.send_email` after that rebinding changes nothing the function will ever see.
      
      Link seams are wonderful for characterization because the production file is untouched. Their cost is coupling: the test now encodes the module layout (`"./mailer"`, `"billing.send_email"`), so file moves and renames break tests even when behavior is identical.
      
      ## Preprocessing Seams
      
      In C and C++, the preprocessor runs before the compiler, so macros can redirect behavior before the code ever compiles:
      
      ```c
      /* db_access.c */
      #ifdef TESTING
        #define db_write(table, rec) fake_db_write(table, rec)
      #endif
      ```
      
      The enabling point is the preprocessor definition (`-DTESTING` in the build). Include-path substitution works the same way: the test build resolves `#include "db_access.h"` to a header full of fakes.
      
      Preprocessing seams are powerful and unsubtle: the code under test is *literally different code* in the test build. Reserve them for environments with no better option — embedded targets, vendor headers, code where even adding an interface is too invasive. Languages outside the C family effectively don't have this seam, and don't miss it.
      
      ## Enabling Points
      
      For every seam, ask: *where is the decision made?* That place is the enabling point, and it must be reachable from your test.
      
      - Object seam → the place the object is created or passed in. If construction happens inside a private method of the class under test, the seam exists but you can't reach its enabling point — you need a factory method to extract and override, or a parameter.
      - Link/import seam → the module registry or patch target, reachable from the test file by name.
      - Preprocessing seam → the build configuration.
      
      "Seam without a reachable enabling point" is the diagnosis behind most "this class is untestable" complaints. The fix is never to test through the UI or the database out of resignation; it is to install a reachable enabling point with the smallest dependency-breaking move available.
      
      ## Seams in Modern Stacks
      
      **DI containers (NestJS, Spring, .NET).** The container's wiring configuration is one giant enabling point. Override a provider in a testing module and every consumer gets the fake — no patching, no subclasses:
      
      ```typescript
      const moduleRef = await Test.createTestingModule({
        providers: [
          BillingService,
          { provide: PaymentGateway, useValue: fakeGateway }, // enabling point
        ],
      }).compile();
      ```
      
      If the codebase already has a container, prefer this seam — it is designed for exactly this substitution and survives refactors that break import-path mocks.
      
      **Config and environment seams.** Scattered `process.env.FEATURE_X` / `os.environ[...]` reads are hidden global inputs. Funneling them through one config object turns configuration into an injectable seam (`new App(config)`), and incidentally documents every knob the system has. Until then, `monkeypatch.setenv("FEATURE_X", "1")` is the import-seam equivalent — workable, global, and easy to leak between tests.
      
      **Clock seams.** Hard-wired `Date.now()` / `datetime.now()` makes time-dependent behavior untestable and flaky. Either install an object seam (inject a `Clock` or a `now()` function with a production default) or use the runtime's link seam: `jest.useFakeTimers()`, Python's `freezegun`, or `time-machine`. For characterization, freezing time is fine; for code you own long-term, the injected clock reads better and works in every runtime.
      
      **Network boundary seams.** Tools like `nock`, MSW, and Python's `responses` fake at the HTTP layer — a link seam at the socket. They shine when characterizing code whose main job is *building requests*: the assertion is the captured request itself. For everything else, faking your own gateway interface is cheaper and less brittle than replaying wire formats.
      
      ## Choosing the Cheapest Seam
      
      Order of preference when you need a seam *now*:
      
      1. **An object seam that already exists** — a constructor or method parameter someone had the sense to add. Zero edits; just pass the fake.
      2. **An import/link seam** — zero production edits, full speed for characterization. Accept the test-to-layout coupling consciously.
      3. **Create an object seam** with Parameterize Constructor, Extract and Override Factory Method, or Adapt Parameter — a small, signature-preserving production edit that pays rent forever after.
      4. **Preprocessing seam** — C/C++ last resort.
      
      The split to internalize: options 1-2 are for *getting tests in place today*; option 3 is the investment that makes the next person's options 1 obvious. A reasonable team policy is "characterize through link seams freely, but any file you change substantively leaves with at least one real object seam."
      
      ## Traps
      
      - **Patch-everything tests.** Ten `mocker.patch` lines before one assertion means the test pins the file layout and call graph, not behavior. Each refactor breaks the test while the system still works — the inverse of what tests are for. Prefer one fake at a real boundary.
      - **Drifting fakes.** Import-seam mocks don't fail when the real signature changes. Use `autospec=True` in Python and typed helpers like `jest.mocked(...)` in TypeScript so fakes break loudly when reality moves.
      - **Seam at the wrong layer.** Faking the ORM's query builder to test pricing logic means asserting SQL strings forever. Move up: fake the repository the pricing code actually talks to. If no such boundary exists, that is the missing seam to install.
      - **Editing while characterizing.** If your "seam" required rewriting the logic you are trying to pin down, you no longer have a characterization — you have a guess wearing one. Back out, pick a seam at a distance, and keep the code under test identical to production.
      - **Singleton state bleeding between tests.** Any seam that mutates global state (static setters, env patches, module mocks) needs a guaranteed reset — an autouse fixture or `afterEach` — or test order starts mattering, which is its own kind of legacy.
      
  • SKILL.md 19.1 KB
    ---
    name: working-with-legacy-code
    description: 'Safely change and test untested codebases using Feathers'' "Working Effectively with Legacy Code". Use when the user mentions "legacy code", "no tests", "untested codebase", "how do I test this", "seams", "characterization tests", "golden master", "sprout method", "afraid to change this code", "monster method", "dependency breaking", or "inherited a messy codebase". Also trigger when changing code without tests safely, getting a class under test when constructors, statics, or singletons block it, adding features to tangled modules, or planning incremental test coverage for an old codebase. Covers the legacy-code change algorithm, seams, characterization tests, sprout/wrap, and dependency-breaking techniques. For refactoring code that already has tests, see refactoring-patterns. For day-to-day code quality, see clean-code.'
    license: MIT
    metadata:
      author: wondelai
      version: "1.2.0"
    ---
    
    # Working Effectively with Legacy Code
    
    A field manual for changing code that has no tests, distilled from Michael C. Feathers' *Working Effectively with Legacy Code*. Use it to get untestable classes into a harness, pin down current behavior with characterization tests, and make changes one safe, verifiable step at a time — without resorting to a rewrite.
    
    ## Core Principle
    
    **Legacy code is simply code without tests.** Not old code, not ugly code — untested code: without tests you cannot know whether a change preserves behavior, so every edit is a gamble. The craft is breaking dependencies just enough to get tests in place before changing anything — cover and modify, never edit and pray.
    
    ## Scoring
    
    **Goal: 10/10.** Rate changes to untested code 0-10 against the principles below. Report the current score and the specific steps needed to reach 10/10.
    
    - **9-10:** Change points covered by characterization tests before any edit; behavior changes and refactoring shipped as separate verified steps; dependencies broken with the least invasive technique
    - **7-8:** Tests at most change points, but occasional mixed refactor-plus-behavior commits or heavier dependency surgery than needed
    - **5-6:** Some characterization tests, yet key paths still changed on faith; sprouted code accumulating with no payback plan
    - **3-4:** Edit-and-pray with manual verification; tests written after the change, asserting whatever the new code happens to do
    - **0-2:** Untested edits straight into tangled code, refactoring and behavior change mixed in one commit, rewrite proposed instead of tests
    
    ## Framework
    
    ### 1. The Legacy Code Dilemma and Change Algorithm
    
    **Core concept:** The dilemma: to change code safely we need tests, but to get tests in place we have to change code. The way out is a fixed sequence — identify change points, find test points, break dependencies, write tests, then make changes and refactor — where the pre-test edits are conservative and mechanical, and the real change happens only inside the safety net.
    
    **Why it works:** Edit-and-pray substitutes care for feedback, and care doesn't scale to code you don't fully understand. Cover-and-modify clamps existing behavior in a vise of tests, so any unintended change announces itself immediately on your machine instead of later in production.
    
    **Key insights:**
    - There are two reasons to change code — changing behavior (feature, bug fix) and improving structure (refactoring) — and mixing them in one step makes failures undiagnosable
    - Test points are rarely the change points: effects propagate, so you often test where the change's effects surface, not where the edit happens
    - Dependency-breaking edits made before tests exist must preserve signatures exactly and lean on the compiler to find every affected site
    - Coverage grows along the paths you actually change — that beats any dedicated "testing project" that never gets funded
    - "Programming is the art of doing one thing at a time": each step of the algorithm is separately verifiable
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | Bug fix in an untested module | Run the five steps before touching the bug | Pin `parseInvoice()` with tests, then fix the rounding error |
    | PR mixing cleanup and a feature | Split into structure-only and behavior-only commits | Extract and rename first, tests green, then add the discount rule |
    | "It's just a one-line change" | Find the nearest test point first | One pin test at the public method that calls the private one you edit |
    
    See [references/change-algorithm.md](references/change-algorithm.md) when running the five steps on a real change — the algorithm as a working procedure with change-point/test-point checklists and triage for "no time" situations.
    
    ### 2. Seams: Where to Pry Code Apart
    
    **Core concept:** A seam is a place where you can alter behavior in your program without editing in that place. Every seam has an enabling point — where you decide which behavior runs. Getting legacy code under test is largely a hunt for seams: spots where a test can substitute a slow, global, or external dependency while the production source stays untouched.
    
    **Why it works:** If you must edit code to test it, you risk changing the very behavior you are trying to pin down. Seams move the substitution to a distance — a subclass, an import, a build flag — so the code under test runs exactly as in production while the test controls its dependencies from the enabling point.
    
    **Key insights:**
    - Object seams are the default in OO code: every overridable call is a seam, and its enabling point is wherever the object is created or passed in
    - Link and import seams swap implementations at build or load time — `jest.mock` and `unittest.mock.patch` are link seams in modern clothing
    - Preprocessing seams (C/C++ macros) are the bluntest instrument; reach for them last
    - `new Database()` inside a method body is a seam that never got built — constructors doing real work, globals, statics, and hard-wired I/O are where seams die
    - A seam without a reachable enabling point is useless: if the test can't make the decision, keep hunting
    - Dynamic languages make nearly every name lookup a seam — cheap, but patching internals couples tests to file layout
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | Class constructs its own DB client | Object seam via constructor parameter | `constructor(db: Db = new ProdDb())` — tests pass a fake |
    | Module calls a top-level `send_email()` | Import/link seam | `mocker.patch("billing.send_email")` or `jest.mock("./mailer")` |
    | Logic reads the wall clock directly | Seam at the clock | Inject a `now()` provider; tests freeze time |
    
    See [references/seams.md](references/seams.md) when hunting a seam in a specific stack — the seam catalog with code, enabling points, and seams in modern tooling (DI containers, jest.mock, pytest monkeypatch, clock and config seams).
    
    ### 3. Characterization Tests
    
    **Core concept:** A characterization test documents what the code actually does right now — not what the spec, the comments, or anyone's memory says it should do. Write a probe you know will fail, let the failure message reveal the real behavior, then change the assertion to pin that behavior in place.
    
    **Why it works:** In legacy systems the actual behavior is the de facto spec: callers, reports, and customers may depend on it, quirks included. Tests written from imagined requirements fail for reasons that tell you nothing, while characterization tests fail during refactoring precisely when — and where — you changed existing behavior.
    
    **Key insights:**
    - The recipe: call the code in a harness, assert something absurd (`expect(total).toBe(-1)`), read the failure, pin the observed value
    - Sensing and separation are the two reasons to break dependencies: separation gets code into a harness, sensing lets assertions see what it computed
    - For complex output (reports, generated files, large JSON) use a golden master: capture the full output once, diff against it forever
    - Snapshot tests are golden masters — review the first snapshot like code and normalize volatile data, or you are pinning noise
    - Found a bug while characterizing? Pin it with a comment and a ticket — downstream code may depend on the wrong behavior; fix it later as a deliberate, separate change
    - Characterize the branches your change will touch, not the whole system — coverage follows change
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | Refactoring a tax calculator | Pin outputs for representative inputs | Run 20 cases through, assert each recorded result |
    | Legacy report generator | Golden master diff | Generate the report, compare to a checked-in master file |
    | Off-by-one found while pinning | Pin the wrong value, document it | `assert days == 30  # BUG? expected 31 — TICKET-482` |
    
    See [references/characterization-tests.md](references/characterization-tests.md) when writing your first probe through to a pinned suite — golden masters, snapshot tests done right, and a worked before/after refactor.
    
    ### 4. Sprout and Wrap: Changing Without Tests First
    
    **Core concept:** When you genuinely cannot get the area under test today, don't weave new logic into the untested mass. Sprout Method or Sprout Class: write the new behavior as fresh, fully tested code and call it from a single line in the legacy spot. Wrap Method or Wrap Class: rename the old code aside and add behavior before or after the call to it, decorator-style.
    
    **Why it works:** New code in a fresh method or class can be test-driven even when its host can't be instantiated in a harness — testability no longer waits on getting the host into a harness. The untested host changes by exactly one call site, so the unverified blast radius is a single line instead of the whole method.
    
    **Key insights:**
    - Sprout Method when new logic plugs in at one point; Sprout Class when the host class won't even instantiate in a test harness
    - Wrap Method suits behavior that surrounds the old code (logging, notification, metering) rather than mixes with it: rename `pay()` to `rawPay()`, recreate `pay()` as the wrapper
    - Wrap Class is the Decorator pattern — use it when several call sites need the added behavior or the class is already bloated
    - Be honest about the trade-off: the host stays untested; you have added good code to a bad neighborhood
    - Track sprouts as debt and pay them back — cover the host the next time a change lands there
    - Sprouting is a tactical move inside the change algorithm, not a permanent substitute for getting code under test
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | Late-fee rule in a 400-line `process()` | Sprout Method, one call line | `total += lateFee(order)` — `lateFee()` written test-first |
    | Audit logging around legacy `pay()` | Wrap Method | New `pay()` logs, calls `rawPay()`, logs again |
    | New validation, class won't instantiate | Sprout Class | `new OrderValidator().validate(data)` called from legacy code |
    
    ### 5. Dependency-Breaking Techniques
    
    **Core concept:** A catalog of mechanical, low-risk moves that sever whatever blocks instantiation or sensing: Extract Interface, Parameterize Constructor, Parameterize Method, Extract and Override Factory Method or Getter, Introduce Instance Delegator, Adapt Parameter, Break Out Method Object, Subclass and Override Method. Because they run before tests exist, always pick the least invasive technique that unblocks you.
    
    **Why it works:** Code resists testing for a small set of recurring reasons — constructors doing real work, statics and singletons, parameters you can't construct, monster methods. Each blocker has a named, practiced counter-move, so you execute a known maneuver instead of improvising surgery on code that has no safety net.
    
    **Key insights:**
    - Parameterize Constructor with a production default is the workhorse: existing callers compile untouched while tests inject fakes
    - Extract Interface is the safest move in the book — introducing an interface can't change behavior, only loosen a type
    - Subclass and Override Method underlies half the catalog: a testing subclass that stubs the dangerous parts is a legitimate tool, not a hack
    - For statics and singletons, Introduce Instance Delegator hands callers an instance they can swap; a static setter can supersede a singleton in tests
    - Adapt Parameter beats fighting unfakeable framework types — wrap `HttpServletRequest` in your own narrow interface and test against that
    - Dynamic languages have cheaper seams: `unittest.mock.patch` or `jest.mock` can stand in for several techniques, but parameterizing leaves better design behind
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | Constructor opens a DB connection | Parameterize Constructor | `def __init__(self, conn=None): self.conn = conn or connect()` |
    | Static `Billing.charge()` called everywhere | Introduce Instance Delegator | Instance `charge()` delegates to the static; tests override it |
    | 900-line method hoarding locals | Break Out Method Object | `new RateCalculation(order, rates).run()` — locals become fields |
    
    See [references/dependency-breaking.md](references/dependency-breaking.md) when a specific blocker stops instantiation or sensing — before/after code for each technique plus a decision table mapping blockers to the right move.
    
    ### 6. Untangling and Understanding
    
    **Core concept:** Before changing code you don't understand, invest in cheap comprehension: effect sketches trace what a change can affect, feature sketches show how methods and fields cluster inside a god class, scratch refactoring means refactoring recklessly to learn and then throwing the edits away, and telling the story of the system forces a simplifying summary. The payoff is finding pinch points — narrow places where a few tests cover wide behavior.
    
    **Why it works:** In legacy code the bottleneck is comprehension, not typing. An effect sketch turns "what could this break?" from anxiety into a finite list, and a pinch point lets a handful of tests act as a vise over an entire cluster of methods — often revealing where a hidden class boundary wants to be drawn.
    
    **Key insights:**
    - Effect sketch: a bubble per variable or method, an arrow per "affects" — trace forward from your change point to every place behavior can leak out
    - A pinch point is a narrowing in the effect sketch; test there and everything upstream of it is covered
    - Scratch refactoring is refactoring as a reading technique: extract, rename, and simplify for an hour, then revert — the insight survives the checkout
    - Monster method strategy: golden-master it at a pinch point, Break Out Method Object, then refactor inside the new class
    - God class strategy: feature-sketch the clusters, then extract along the natural boundaries between them
    - Triage when there's no time: a spot changing once gets a sprout or wrap; the same spot changing again has earned its tests
    
    **Applications:**
    
    | Context | Application | Example |
    |---------|-------------|---------|
    | "What breaks if I change this field?" | Effect sketch from the field outward | Three readers found; two pinch-point tests cover them |
    | Feature due in a 5,000-line class | Pinch-point tests, then sprout | Cover `postInvoice()`, sprout the new rule as a class |
    | Code nobody on the team understands | Scratch refactor on a branch | Extract and rename to learn, revert, plan the real moves |
    
    See [references/case-studies.md](references/case-studies.md) when you want a full worked walkthrough — three scenarios: a feature in an untested 800-line service, a singleton-ridden module brought under test, and a monster method tamed before a bug fix.
    
    ## Common Mistakes
    
    | Mistake | Why It Fails | Fix |
    |---------|-------------|-----|
    | Refactoring and changing behavior in one step | When something breaks, you can't tell which edit did it | Separate commits; tests green between each step |
    | Writing "should" tests on legacy code | Imagined specs fail noisily and you "fix" load-bearing behavior | Characterize what the code does; file bugs separately |
    | Mocking everything in sight | Tests pin the implementation, so every refactor breaks them | Fake only what blocks instantiation or sensing |
    | Big-bang rewrite instead of incremental coverage | The old system keeps moving; rewrites ship late and miss years of edge cases | Cover and modify piece by piece |
    | Silently fixing bugs found while characterizing | Callers and reports may depend on the wrong behavior | Pin it, document it, fix it as a separate deliberate change |
    | Invasive cleanup before any tests exist | Every manual edit risks behavior with no net underneath | Least invasive technique; preserve signatures; lean on the compiler |
    | Sprouting forever without payback | The host stays untested and sprouts ossify into the next legacy layer | Track sprout debt; cover hot spots on the next touch |
    | Waiting for a dedicated "testing project" | That project never gets funded; coverage never appears | Grow coverage along every change you ship |
    
    ## Quick Diagnostic
    
    | Question | If No | Action |
    |----------|-------|--------|
    | Do tests cover the code you're about to change? | You're editing and praying | Run the change algorithm; pin behavior before editing |
    | Can you construct the class in a test harness? | Dependencies block separation | Parameterize Constructor, Extract Interface, or Sprout Class |
    | Can a test sense the effect of your change? | Effects are invisible to assertions | Find a sensing point; Extract and Override Getter |
    | Is this commit behavior-only or structure-only? | Mixed | Split it; run the tests between the two |
    | Do you know everything this change can affect? | Unknown blast radius | Draw an effect sketch; test at the pinch points |
    | Do your assertions state observed behavior? | Testing wishes | Probe, read the failure, pin the actual value |
    | Is the seam you chose the cheapest one available? | Needless surgery | Prefer constructor parameters and import seams first |
    | Will the code be better covered after this change? | The next change costs as much as this one | Leave at least one pin test at the nearest test point |
    
    ## Further Reading
    
    - [*"Working Effectively with Legacy Code"*](https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052?tag=wondelai00-20) by Michael C. Feathers
    - [*"Refactoring: Improving the Design of Existing Code"*](https://www.amazon.com/Refactoring-Improving-Existing-Addison-Wesley-Signature/dp/0134757599?tag=wondelai00-20) by Martin Fowler
    - [*"Tidy First?: A Personal Exercise in Empirical Software Design"*](https://www.amazon.com/Tidy-First-Personal-Exercise-Empirical/dp/1098151240?tag=wondelai00-20) by Kent Beck
    - [*"Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)"*](https://www.amazon.com/Kill-Fire-Manage-Computer-Systems/dp/1718501188?tag=wondelai00-20) by Marianne Bellotti
    
    ## About the Author
    
    **Michael C. Feathers** is the founder of R7K Research & Conveyance, a consultancy focused on software design and the rehabilitation of aging systems. A long-time consultant and conference speaker on legacy code, he wrote *Working Effectively with Legacy Code* (2004) and gave the field its working definition: legacy code is simply code without tests.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related