{ } Programming

Safe refactoring: improving code without breaking it

How to refactor safely: tests as a safety net, small steps, techniques such as extracting functions and renaming, and how to approach legacy code.

Refactoring means changing the internal structure of code without changing its external behaviour. The goal is to make the code easier to understand and to modify: renaming, moving, splitting, simplifying. The risk is obvious: every change can introduce an error. That is why good refactoring is not a great heroic intervention, but a sequence of small verified steps, each of which leaves the program working.

Refactoring is not rewriting

The distinction matters. Rewriting a module from scratch means replacing working code, with all the special cases accumulated over the years, with new, untested code. Refactoring, on the other hand, transforms existing code gradually, keeping it in a working state at all times.

A practical sign: if during a "refactoring" the program stays broken for days, you are not refactoring, you are rewriting.

It is equally important not to mix refactoring and new features in the same step. First you restructure, checking that behaviour does not change; then you add the new feature on the now cleaner code. Two separate changes are easier to verify and to undo.

The safety net: tests

Without tests, refactoring is an act of faith. Before touching a piece of code, check that it is covered by tests verifying its current behaviour. If there are none, the first move is to write them.

With legacy code, it is often unclear what the "correct" behaviour is. In these cases you use characterization tests: tests that capture what the code does today, even if it looks odd.

def test_characterization_shipping_cost():
    # We record current behaviour, not ideal behaviour
    assert shipping_cost(weight=0, zone="north") == 4.90
    assert shipping_cost(weight=2.5, zone="islands") == 12.40
    assert shipping_cost(weight=30, zone="south") == 18.00

If one of these tests fails during refactoring, you know you have changed the behaviour. If you discover that the current behaviour is a bug, you fix it in a separate, deliberate step.

The rhythm of small steps

An effective work cycle:

  1. run the tests and check that they all pass;
  2. make a single small transformation;
  3. run the tests again;
  4. if they pass, commit; if they fail, undo and try a smaller step.

Frequent commits mean that, if something goes wrong, you go back a few minutes of work, not an entire afternoon.

The most useful transformations

Extract function

The most common refactoring. A block of code with a recognisable purpose becomes a function whose name describes that purpose.

# Before
def print_invoice(order):
    total = 0
    for line in order.lines:
        total += line.price * line.quantity
    if order.customer.is_premium:
        total *= 0.9
    ...

# After
def order_total(order):
    total = sum(l.price * l.quantity for l in order.lines)
    return total * 0.9 if order.customer.is_premium else total

def print_invoice(order):
    total = order_total(order)
    ...

Now the total calculation can be tested on its own and reused elsewhere.

Other frequent transformations

Transformation When to apply it
Rename A name no longer describes what it represents
Replace magic numbers with constants Values like 0.9 or 86400 appear without explanation
Introduce a parameter object A function receives six or seven related arguments
Simplify conditionals if statements nested several levels deep or long, repeated conditions
Use guard clauses Exit early from special cases instead of nesting everything in an else
Move function A function uses more data from another module than from its own

When to refactor

The best moment is not an abstractly planned "refactoring month", but everyday work. Some natural opportunities:

  • before adding a feature: if the current code makes the change difficult, first restructure it to make the change easy, then make the change;
  • when fixing a bug: understanding the bug often reveals a confusing structure that encouraged it;
  • during code review: a second look spots duplication and unclear names.

A simple rule, often associated with the scouting world, sums up the attitude: leave the code a little cleaner than you found it. Small continuous improvements, added up over time, are worth more than rare large interventions.

Tools help

Modern editors offer automated refactorings: renaming, extracting functions and variables, moving methods. They are safer than changes by hand, because they analyse the code and update every reference. In dynamic languages such as Python, the analysis is less precise than in statically typed ones, and tests remain indispensable. Adding type annotations and a static checker makes these tools much more reliable.

In short

  • Refactoring changes the structure of code without changing its behaviour.
  • Tests are the safety net: with legacy code, start with characterization tests.
  • Proceed in small steps, running the tests and committing after each transformation.
  • Do not mix refactoring and new features in the same change.
  • Make refactoring part of daily work, before changes and fixes.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. With degrees in computer science and psychology, he combines technical analysis with an understanding of the human factor.

Read also