software-design/refactoring-tidy-messy-code-without-breaking-it-using-tests-as-a-net.md

Refactoring: tidy messy code without breaking it, using tests as a net

Building software · 5 min read ·

Refactoring means changing how code is written without changing what it does, so the next person (often you) can read it. It sounds risky, and it is, unless you have a safety net.

Here you get a messy shipping cost function, eight tests, and a live editor. Tidy the code step by step and watch the tests stay green, then try a "harmless" edit that quietly breaks one rule and see exactly which test catches it.

Messy nested if statements turning into three flat lines of code, with 8 of 8 tests passing

The shipping function that nobody wants to touch

Imagine you work on a small online shop. Months ago someone wrote a function that works out the shipping cost, and it has been correct ever since. Now you have to add a new rule, and you open the file and wince. The function is full of nested if statements and unexplained numbers. You are afraid that if you change anything, something quietly breaks.

The usual advice is to refactor first. Refactoring means changing how code is written without changing what it does. The inputs and outputs stay exactly the same. Only the inside gets easier for a human to read. Think of tidying a messy kitchen: the meals you can cook do not change, but now you can find the salt.

The messy version

Here is the function, in JavaScript. It takes the parcel weight in kilograms, the total price of the order, and whether the customer is a member. Orders of 50 or more ship free. Otherwise the price depends on weight: 4 up to 1 kg, 7 up to 5 kg, 12 above that. Members get 2 off any shipping that is not already free.

function shippingCost(weightKg, total, isMember) {
  var c;
  if (total >= 50) {
    c = 0;
  } else {
    if (weightKg <= 1) {
      c = 4;
    } else {
      if (weightKg <= 5) {
        c = 7;
      } else {
        c = 12;
      }
    }
  }
  if (isMember && c > 0) {
    c = c - 2;
  }
  return c;
}

It works, but you have to read every line to learn that. What is c? Why 50? Why 2? Is that final c > 0 check needed?

Step one: a safety net

You must never refactor blind. Before touching the code, write down a few examples of what it should return, each with the exact answer you expect. These are tests: tiny questions with known answers that you can re-ask in a second. If all of them still pass after your edit, you have good evidence (not a proof) that you changed nothing important. If one fails, you know right away, while the change is still small.

Good tests include the edges, the places where behaviour switches. Here that means a parcel of exactly 1 kg, an order of exactly 50, and one just under 50. Most mistakes hide at the edges.

Try it: refactor and watch the tests

The box below runs real JavaScript, right here in your browser, against eight tests. Press the buttons to load each stage of the clean-up, or edit the code yourself and press Run tests. The orange button loads a tempting “tidy-up” that is secretly a mistake.

What just happened

Each stage of the clean-up is small and does one thing. Naming the numbers cannot change the result, because FREE_SHIPPING_FROM holds exactly the value 50. Flattening the if statements is a bigger change, and that is where the tests earn their keep. Notice that the stage-two version dropped the c > 0 check. That was safe only because the early return 0 already handles every free order, and every paid rate is above 2. The tests are what let you check that claim in a second instead of arguing about it.

Now try the risky tidy-up. Changing >= to > looks harmless, and it compiles and runs without any error. But an order of exactly 50 stops shipping free. Only the two tests that sit right on that edge notice, which is why edge tests matter. A bug like this could sit in a shop for months before a customer complained.

The rules of thumb

Change one thing at a time. If you rename, flatten and extract all at once and a test fails, you will not know which change caused it. Run the tests after every step. Do not mix refactoring with new features. First make the code easy to change with no change in behaviour, and only then add the new rule. If something breaks, you will know it was the new rule.

And what if you have no tests? Then writing them is step zero. Even four or five examples taken from the code’s current behaviour will protect you. Real projects usually have a test runner that re-runs hundreds of such checks automatically, but the idea is the same as the table above.

Why bother?

Code is read far more often than it is written, by teammates and by your future self. Readable code makes the next change cheaper and the next bug easier to see. Refactoring is how you pay that cost down in small, safe pieces, instead of in one frightening rewrite. Next time you open a function and wince, write a few tests first, then tidy in small steps.

$ ls software-design/

see all →
software-design/the-observer-pattern-how-a-shop-tells-many-functions-about-one-order-w.md The observer pattern: how a shop tells many functions about one order without knowing them A shop that must email a receipt, update stock and add loyalty points ends up with one function that knows about everything. Every new… 5 min read · 27 Oct software-design/refactoring-how-to-tidy-working-code-without-breaking-it.md Refactoring: how to tidy working code without breaking it Refactoring means changing how code is built on the inside without changing what it does. It sounds risky, but there is a simple routine… 6 min read · 24 Oct software-design/unit-tests-why-four-passing-tests-don-t-prove-a-function-works.md Unit tests: why four passing tests don't prove a function works Your function gave the right answer four times. Is it correct? Not necessarily: passing tests only show the bug isn't where you looked… 4 min read · 21 Oct software-design/idempotency-why-clicking-pay-twice-should-charge-you-once.md Idempotency: why clicking Pay twice should charge you once You tap Pay, the screen shows an error, you tap again, and your card is charged twice. Networks drop messages, so a phone often cannot tell… 6 min read · 18 Oct software-design/state-machines-why-an-order-can-t-be-delivered-before-it-s-paid.md State machines: why an order can't be delivered before it's paid An online order can be in the cart, paid, shipped, delivered or cancelled, but what stops the code from delivering an order nobody paid… 4 min read · 15 Oct software-design/dependency-injection-why-a-test-passes-at-9am-and-fails-at-3pm.md Dependency injection: why a test passes at 9am and fails at 3pm A test that passes in the morning and fails in the afternoon, with no change to the code, is a puzzle many beginners meet. The cause is… 5 min read · 12 Oct