Refactoring Without Tests
Sometimes you have to change code whose behaviour nothing protects. The technique is a small number of provably-safe moves, used to buy a seam, used to get a characterization test in place.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
The code has no tests, I cannot add tests without changing it, and I have to change it. Where does that loop break?
A nine-year-old invoicing routine has to support a new payment term. It is 700 lines, has no tests, reads the clock directly, calls a payment API, writes four tables and sends email. Nobody who wrote it still works here.
Write tests first. It is the professional answer, and until there are tests, nothing should be touched.
It is circular, and the circularity is real. Testing the routine requires injecting the clock, the payment API and the mailer; injecting them is a change to untested code, which is the thing you were not allowed to do.
- It is circular, and the circularity is real. Testing the routine requires injecting the clock, the payment API and the mailer; injecting them is a change to untested code, which is the thing you were not allowed to do.
- Full coverage of a 700-line routine that touches four tables and two external systems is weeks of work, and the payment term is due before the next run — so "tests first" in its strong form means the change happens with no discipline at all, under deadline, which is the worst available outcome.
- It also over-tests. Most of those 700 lines are not going to be touched; characterizing all of it spends the budget on code that carries no risk in this change (When Design Does Not Pay).
- And it misses that there is a middle. A small set of transformations are safe enough to make without tests — ones a compiler verifies, or ones so local that reading them is genuine verification — and those are exactly enough to create the seam that makes a test possible (Seams).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- It runs monthly and is worth several million a year; a wrong invoice is a customer incident and a finance incident at once.
- Adding a test requires constructing the whole world it touches, which is why nobody has.
- The new payment term is due before the next monthly run.
- Nobody can describe its current behaviour in full; the code is the specification (What "Legacy" Actually Means).
- Whatever it does today for existing customers, it must keep doing — including any behaviour that looks like a bug, until removing it is a separate, deliberate decision.
- No step may leave the routine unable to run, because the monthly run is not optional.
- Every step that is not mechanically verifiable must be small enough to review by reading (Review Size).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The provably-safe steps own creating a seam, and nothing else. They are not an opportunity to improve anything (What Refactoring Actually Is).
- The characterization tests own pinning current behaviour, including the parts that look wrong (Characterization Tests).
- The seam owns making the external world substitutable — clock, payment API, mailer, database (Time as a Dependency).
- The behaviour change owns being last, separate, and small.
- The line between safe and unsafe steps is verification, not size: a compiler-checked rename across 200 files is safer than a hand-edited three-line conditional (Rename).
- The scope boundary is the change you actually have to make. Characterize the behaviour your change can affect and leave the rest of the 700 lines alone (The Legacy Change Loop).
- The seam boundary is the outermost point where you can substitute the world and still exercise the logic you care about — usually the routine's entry point rather than its internals (What a Unit Is).
Breaking the circle
The loop is: I cannot test it without changing it, and I should not change it without tests. It breaks at exactly one point — a small set of moves safe enough to make unverified, whose only purpose is to make the first test possible.
Everything before step four is preparation. The discipline is that none of those steps may improve anything.
- 11. Observe
Run it against a copy of real data and record everything it produces: invoice rows, table writes, outbound email. No code changes at all.
fails by A baseline recorded from an unrepresentative period, so the annual-billing customers are absent and the suite is green on cases that never occur in it.
- 22. Provably-safe moves only
Tooling-performed renames, extractions with no control-flow change, a parameter introduced with a default equal to the current value. Nothing that requires judgement.
fails by "While I am here." One unverified judgement call hidden inside 200 lines of mechanical diff, at the point of maximum risk (Review Size).
- 33. Open a seam
Make the clock, the payment API, the mailer and the database substitutable — a field with a real default, injected only in tests (Seams).
fails by Opening the seam too deep, so the test exercises a fragment rather than the behaviour that matters (What a Unit Is).
- 44. One characterization test
Run the routine with the world substituted, capture the output, diff against the step-1 baseline. The circle is now broken.
fails by Nondeterminism — a timestamp or a generated id in the output — so the first diff is noise and everyone loses faith in the approach (A Deterministic Core).
- 55. Widen, but only where it matters
Add cases covering the paths your change can affect, drawn from real data. Adding the second test is cheap now.
fails by Trying to characterize all 700 lines, which spends the budget on code carrying no risk in this change.
- 66. Refactor with the net
The ordinary loop, small steps, verify each one (The Refactoring Loop).
fails by Going straight to the behaviour change and skipping the restructuring that would have made it four lines.
- 77. Change behaviour, last and alone
One commit, its own test, and a deliberate reading of every characterization diff it produces.
fails by Updating the failing characterization tests to match the new output without reading them — which converts the change detector into a rubber stamp (Characterization Tests).
If the deadline does not allow steps one to six, the honest fallback is a contained new branch for the new payment term that leaves the existing path byte-for-byte untouched, plus a written debt record saying what was skipped and what would trigger doing it properly (The Debt Register).
What a safe move looks like
The seam-opening step is the one people get wrong, usually by making it larger than it needs to be. The move below is deliberately unambitious: it changes no call site, no signature that any caller uses, and no behaviour, and it is the entire thing that makes a test possible.
1// before — untestable: real clock, real API, real mailer2function runMonthlyInvoicing(customers: Customer[]) {3 const now = new Date()4 ...5 await paymentApi.charge(inv.total, c.cardToken)6 await mailer.send(c.email, render(inv))7}8 9// after — same behaviour, same call sites, testable10interface World {11 now(): Date12 charge(amount: Money, token: string): Promise<ChargeResult>13 send(to: string, body: string): Promise<void>14}15 16const PRODUCTION: World = {17 now: () => new Date(),18 charge: (a, t) => paymentApi.charge(a, t),19 send: (to, b) => mailer.send(to, b),20}21 22function runMonthlyInvoicing(23 customers: Customer[],24 world: World = PRODUCTION, // default preserves behaviour25) { ... }The default argument is what makes this safe: every existing caller compiles and behaves identically, so the change is verified by the type checker rather than by a test that does not exist yet. World is deliberately one crude interface rather than three well-designed dependencies — splitting it properly is a later step, done with the net in place. Doing the good design now would be the judgement call this step is not allowed to make.
The states the code passes through
Each state below is a real place the code can be in, and the value of naming them is the forbidden transitions — every one of which is something teams do under deadline, and every one of which removes the property the previous step was there to establish.
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| unprotected | run against recorded production inputs | observed | — | a baseline exists that no later step may silently invalidate |
| observed | provably-safe moves | seamed | compiler-verified, or small enough to verify by reading | clock, external calls and I/O become substitutable (Time as a Dependency) |
| seamed | first test reproduces the baseline | characterized | — | the circle is broken; further tests are cheap |
| characterized | the ordinary refactoring loop | restructured | — | — |
| restructured | a separate commit that changes behaviour | changed | every characterization diff read and deliberately accepted | — |
| characterized | the change needs no restructuring | changed | same — every diff read | — |
| unprotected | no time, and the risk is unacceptable | contained | — | a debt record with the trigger for doing it properly (Deliberate Debt) |
- unprotected → restructured — Restructuring code whose behaviour nothing has captured is a rewrite with a smaller diff. There is no baseline, so "it still works" is an assertion nobody can check — and on a monthly job the check arrives four weeks later, in the ledger.
- unprotected → changed — Changing behaviour you cannot describe means you cannot tell your intended change from an accidental one. This is the transition that produces the incident this whole lesson is written to avoid (What "Legacy" Actually Means).
- observed → characterized — Skipping the seam means the test constructs the real world — real clock, real payment API, real mailer — so it is nondeterministic, slow and occasionally charges someone. Teams do try this, and the resulting suite gets disabled within a month (Seams).
- restructured → changed — Forbidden as a *single commit*: mixing a structural move with a behaviour change makes the diff unreviewable and makes a later bisect useless, which are the two things the whole sequence was buying (What Refactoring Actually Is).
The contained state is a legitimate terminal state, not a failure — the failure is reaching it without writing down what was skipped. What makes it acceptable is that the existing path is untouched, so the blast radius of the new payment term is exactly the new payment term.
How to build it
Most important first.
- Observe before touching. Run it against a copy of production data and record the outputs — the invoices, the rows written, the emails that would have gone out. That recording is the baseline, and it is available before any code changes (The Legacy Change Loop).
- Use only provably-safe moves to open a seam: tooling-performed rename, extract method with no change to control flow, introduce a parameter with a default that preserves the current value, wrap the direct clock or API call in a field that defaults to the real one (Seams).
- Get one characterization test running end to end against the recorded baseline. This is the moment the loop breaks, and everything before it is in service of it (Characterization Tests).
- Now widen: with one test running, adding cases is cheap, so characterize the paths your change can affect.
- Refactor with the net in place, in the ordinary loop (The Refactoring Loop).
- Make the behaviour change last, as its own commit with its own test. Characterization tests that now fail are your diff — inspect every one and either accept it deliberately or fix the change (What Refactoring Actually Is).
- If none of this is possible before the deadline, say so and make the smallest possible change with the tightest possible blast radius — a new branch for the new payment term that leaves the existing path untouched. Ugly, contained, and honest (Deliberate Debt).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Doing it with no net: the change costs an afternoon and an unbounded tail risk. If it is wrong, it is discovered by a customer, after the monthly run, in the finance ledger — which is the most expensive place to discover anything.
- Doing it with the loop above: the change costs several days, most of it spent before any behaviour moves. What it buys is that the next change to invoicing costs a day, because the seam and the baseline are now permanent assets (The Cost of Change).
- The second change is where this pays back, and there is almost always a second change. Framing the investment as belonging to this ticket alone is what makes teams skip it (Interest: Why Debt Compounds).
- What does not get cheaper: the 700 lines you deliberately did not characterize are still unprotected, and the next change in that region starts the process again from the beginning.
- This is slow. Several days before a single line of new behaviour, on a deadline, and it will be questioned every time.
- Characterization tests encode current behaviour including its bugs, which means they will actively resist a future correct change until someone reads them carefully.
- Provably-safe steps are limited in what they can achieve. There is a real category of code — heavy static coupling, framework-driven construction, global state — where no safe step reaches a seam, and this approach simply does not apply (Hidden Global State).
What can go wrong
- The safe steps stop being safe. "Extract method" turns into "extract method and tidy the conditional", and the one unverified change is inside 200 lines of mechanical diff (Review Size).
- The baseline is not representative. Recorded from a quiet month, it misses the annual-billing customers, and the characterization suite is green on a case that does not occur in it.
- The characterization tests pin a bug, someone later "fixes" the test rather than making a deliberate decision, and the bug is now unprotected again (Characterization Tests).
- The mitigation itself fails: a test suite so tightly pinned to current output that the intended change breaks four hundred assertions, and the team deletes the suite rather than reading the diff.
- The parallel path is taken as the shortcut, and never converges. Two invoicing routines, both live, diverging quietly (Incremental Migration).
- The whole approach depends on being able to run the code against realistic inputs. Where production data cannot be copied, a sanitised sample or recorded requests are the substitute, and getting them is often the longest part (Production Data in Lower Environments in DevOps).
- It depends on a seam being reachable with safe moves. Occasionally it is not — static calls, framework magic and global state can make the outermost seam unreachable, and then the honest answer is a contained parallel path (Hidden Global State).
- Characterization tests depend on the output being deterministic, which is why the clock and any generated ids are the first things to substitute (A Deterministic Core).
- "So it is fine to refactor without tests." It is possible with a specific, narrow set of moves whose purpose is to get a test in place. Continuing to restructure without a net is not this technique; it is unverified rewriting (What Refactoring Actually Is).
- "Characterization tests are technical debt." They are a record of behaviour nobody else wrote down. They should evolve into intent-revealing tests, and until then they are the only specification that exists (What "Legacy" Actually Means).
- "Just rewrite it — it is nine years old and nobody understands it." The behaviour nobody understands is exactly what a rewrite has to reproduce, and the rewrite has no baseline to check itself against (The Risk in a Rewrite).
- "Get the code under test first, always." Sometimes the deadline genuinely does not permit it, and the honest response is a contained change with a small blast radius plus a written record of what was skipped — not a pretence that the discipline was followed (The Debt Register).
- god-object
Testing it, and how it ages
- The first test is not a unit test. It is: run the routine against recorded inputs, capture everything it writes and sends, and diff against the baseline (The Legacy Change Loop).
- Substitute the clock and any id generator first — without that, nothing is comparable between runs (Randomness as a Dependency).
- Characterize the edge cases you can find in real data — proration, credits, zero-value invoices, the customer with 400 line items — because invented inputs test the behaviour you imagined rather than the behaviour that exists.
- Once the change is made, read every characterization diff. A characterization suite is a change detector, and each detected change is a question rather than a failure (Characterization Tests).
- The seam is the durable asset. Long after the payment-term ticket is forgotten, the routine is testable, and that changes what the team is willing to do to it (Seams).
- Characterization tests should be gradually replaced by tests that assert intent. A test saying "invoice total is 1,204.55" is a change detector; one saying "annual plans are prorated to the day" is a specification, and the second is what you want in three years.
- What forces a rethink: if opening a seam requires more risk than the change itself, the code may need containment rather than refactoring — a strangler around it rather than surgery inside it (The Strangler Pattern).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe circularity — you need a seam to test and a test to change safely — is a property of unprotected code everywhere; what differs is which moves count as provably safe, which is a function of the language and the tooling.
- LANGUAGE-SPECIFICIn a typed language with refactoring tooling, renames, signature changes and extractions are compiler-verified, so quite a lot can be done before any test exists. In a dynamic language almost nothing is verified, so the safe set shrinks to changes small enough to read exhaustively — and the seam usually has to be opened by monkey-patching or dependency lookup rather than by changing a signature.
- LIFETIME-SPECIFICFor a routine with years left, the seam and the baseline repay several times over. For code being decommissioned in three months, the correct answer is the contained parallel branch and no investment at all — and knowing which of the two you are looking at is part of the judgement (When Design Does Not Pay).
- CONTESTEDThe strongest opposing view is that characterization tests are a trap: they lock in behaviour nobody has validated, they are expensive to maintain, they break on every legitimate change, and the effort would be better spent on production observability plus a fast rollback — so that a wrong invoice is detected and reverted rather than prevented. That is a coherent strategy and it works well where the blast radius is reversible. It works badly here, because a wrong invoice sent to a customer is not revertible by a deploy.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — characterization testing, golden-file comparison and how much confidence a recorded baseline actually provides all belong there; this lesson only uses them as a means of making a change safe.