Small Steps and Tests
The rhythm
Section titled “The rhythm”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:
- Make one small, behavior-preserving change.
- Run the tests.
- 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"] Why small steps?
Section titled “Why small steps?”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.
Why a test safety net?
Section titled “Why a test safety net?”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.
A worked tiny-steps example
Section titled “A worked tiny-steps example”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).
// Beforefunction discount(total: number, member: boolean): number { let rate = 0; if (member) { if (total > 100) { rate = 0.2; } else { rate = 0.1; } } return total * rate;}
// Afterfunction discount(total: number, member: boolean): number { if (!member) return 0; const rate = total > 100 ? 0.2 : 0.1; return total * rate;}# Beforedef discount(total, member): rate = 0 if member: if total > 100: rate = 0.2 else: rate = 0.1 return total * rate
# Afterdef discount(total, member): if not member: return 0 rate = 0.2 if total > 100 else 0.1 return total * rate// Beforefunc Discount(total float64, member bool) float64 { rate := 0.0 if member { if total > 100 { rate = 0.2 } else { rate = 0.1 } } return total * rate}
// Afterfunc Discount(total float64, member bool) float64 { if !member { return 0 } rate := 0.1 if total > 100 { rate = 0.2 } return total * rate}// Beforefn discount(total: f64, member: bool) -> f64 { let mut rate = 0.0; if member { if total > 100.0 { rate = 0.2; } else { rate = 0.1; } } total * rate}
// Afterfn discount(total: f64, member: bool) -> f64 { if !member { return 0.0; } let rate = if total > 100.0 { 0.2 } else { 0.1 }; 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.