Refactor to Tests — Refactoring Patterns, Part 9
Refactoring Patterns · Part 9
You can't safely refactor code you can't test — and the code that most needs refactoring is usually the legacy tangle that has no tests at all. The way in is characterization tests: tests that pin down what the code currently does (bugs and all) so you can change its shape without changing its behavior. This walks through getting untestable code under test so you can finally refactor it.
No tests = no refactor. Add tests first; refactor with confidence.
Step 1: Why Tests First (15 min)
Refactor = change without changing behavior.
Without tests:
Can you tell if behavior changed? Maybe.
Can you tell easily? No.
Are you brave enough to ship? No.
Tests = behavior contract. Refactor against them.
Step 2: Characterization Tests (15 min)
Code has no tests; behavior unknown:
test('processOrder(input1) returns output1', () => {
// capture actual current output
expect(processOrder(input1)).toBe("???") // run; copy actual; save
})
Pin the current behavior. May be buggy. Pin anyway.
Then refactor: tests catch behavior changes (good or bad).
Step 3: Cover Critical Paths (15 min)
Don't aim for 100% before refactoring.
Happy path
Common edges
Known bug behaviors
20% coverage that covers 80% of risk: useful.
Step 4: Identify Seams (15 min)
Seams: places to insert tests without changing code under test.
Public function entry points
Class constructors with dependencies
Module imports
Find seams; write tests there.
Step 5: Sprout Method (15 min)
Need to add behavior to untested function?
function bigUntested() {
// ... lots of code ...
computeTax(...) // new function with new tests
// ... lots of code ...
}
// Test computeTax separately. Big function still untested but new code is.
Incremental coverage growth.
Step 6: Sprout Class (15 min)
Extract new feature to a class:
Existing code unchanged
New class fully tested
Old code calls new class
Future refactors slowly expand tested area.
Step 7: Wrap (15 min)
Wrap existing function:
function originalFunc() { ... }
function wrappedFunc() {
// log; metric; whatever
originalFunc()
}
Tests wrap; original code unchanged.
Step 8: Break Dependencies (15 min)
Untestable code often: tight infrastructure coupling.
function processOrder() {
const db = new RealDatabase()
...
}
Inject:
function processOrder(db: Database) {
...
}
Tests pass mocks/fakes. Production passes real.
Step 9: Don't Test Implementation (15 min)
Test inputs → outputs. Behavior.
Don't test:
Private methods
Internal state
Calls to dependencies (unless behavior)
Implementation-dependent tests: break on refactor. Defeats purpose.
Step 10: Routine (15 min)
Every time you touch untested code:
Add 1-2 tests for what's there
Then change
Slowly: codebase becomes more testable.
Years pass: well-covered. Refactors safe.
What You Just Did
Refactor to Tests: why tests first, characterization tests, cover critical paths, identify seams, sprout method, sprout class, wrap, break dependencies, don't test implementation, routine.
Common Failure Modes
Refactor without tests. Regressions.
Aim for 100% before any refactor. Never start.
Test private methods. Brittle.
Mocks everywhere. Test fragile.
Skip tests for "simple" code. Surprise bugs.
Continue the Refactoring Patterns path
Previous — Part 8: Split Phase
Part of the Refactoring Patterns learning path.


