top of page

Refactor to Tests — Refactoring Patterns, Part 9

Shawn West
Jul 30
2 min read
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

Part of the Refactoring Patterns learning path.

bottom of page