Skip to content

Small Steps and Tests

Refactoring done well looks almost boring. You do not stare at a function and then retype it dramatically. You make one tiny change, confirm nothing broke, and save your progress. Then you do it again. The loop is:

  1. Make one small, behavior-preserving change.
  2. Run the tests.
  3. If they pass, commit. If they fail, undo and take a smaller step.
flowchart TB
  A["Make ONE tiny<br/>behavior-preserving change"] --> B["Run the tests"]
  B -->|green| C["Commit"]
  B -->|red| D["Undo the last step"]
  D --> E["Take a smaller step"]
  E --> A
  C --> F{"Done?"}
  F -->|no| A
  F -->|yes| G["Stop"]
The refactoring rhythm — small change, test, commit, repeat until done

The size of your steps determines how far you can fall. If you change fifty lines and the tests go red, the bug could be in any of them — you are back to debugging. If you change one thing and the tests go red, you know exactly what caused it, because nothing else moved. Undo that single step and you are instantly back to a working program.

Small steps feel slower. They are not. The time you “lose” taking many tiny steps is far less than the time you lose hunting a regression through a giant tangled diff. Small steps trade a feeling of speed for the reality of never being lost.

The whole definition of refactoring is behavior preservation. Tests are how you prove it. After each step, a green bar is your evidence that the change you just made was invisible to the outside world. Without tests, you are only hoping behavior was preserved — and hope is not a safety net.

If the code you want to refactor has no tests, write some first. Even a few characterization tests — tests that simply pin down what the code does today, correct or not — give you the net you need to restructure with confidence.

We will turn an awkward conditional that returns a discount rate into a clear, flat structure. Watch the steps: introduce an explaining name, then flatten the nesting. Run the tests after each step (shown here as the final state, but you would commit twice).

// Before
function discount(total: number, member: boolean): number {
let rate = 0;
if (member) {
if (total > 100) {
rate = 0.2;
} else {
rate = 0.1;
}
}
return total * rate;
}
// After
function discount(total: number, member: boolean): number {
if (!member) return 0;
const rate = total > 100 ? 0.2 : 0.1;
return total * rate;
}

The same inputs yield the same discount. Each intermediate state still compiled and passed — that is what let us keep moving without fear.

What are the three beats of the refactoring rhythm?
Why prefer many small steps over one large change?
What role do tests play during refactoring?
If code you want to refactor has no tests, what should you do first?