Skip to main content
Back to module
Software Engineering Practices · Quality

Refactoring

Reading1h 7mLesson 2 of 3 in this module

Spot code smells and refactor in small steps while characterization tests keep behaviour the same.

Why it matters

Refactoring means changing the structure of code without changing what it does. You refactor so the next change is easier: so a new feature fits cleanly, a bug is easier to find, or a teammate can understand the code in minutes instead of hours. The key word is without changing what it does. A refactor that quietly changes behaviour is a bug, and the only reliable way to know you have not changed behaviour is a set of tests that you run after every small step.

Code smells: signs a refactor would help

A code smell is a surface sign that something deeper may be wrong. It is a prompt to look, not proof of a problem.

  • Long function: you have to scroll, and comments separate the "sections".
  • Deep nesting: if inside if inside for, so it is hard to see which conditions lead where.
  • Magic numbers and strings: 0.0825, "G", 365 with no name.
  • Duplicated logic: the same rule written in two places that must change together.
  • Long parameter list: five positional arguments that callers mix up.
  • Unclear names: d, tmp, data2, doStuff.

The safe loop

  1. Make sure tests cover the behaviour. If the code has none, write characterization tests first: call the existing code with representative inputs and record what it returns today, even if it looks odd. Those tests pin down current behaviour.
  2. Make one small change. Rename a variable. Extract one function. Replace one magic number with a constant.
  3. Run the tests. Green: commit, or at least keep going. Red: undo the step; it was not a pure refactor.
  4. Repeat.

Small steps feel slow but are fast overall, because when a test fails you know exactly which change caused it. Keep refactoring commits separate from behaviour changes so reviewers can check each one easily.

Worked example

Here is a legacy shipping function:

function ship(w, c, p) {
  let r = 0;
  if (c === "US") {
    if (p) {
      r = 0;
    } else {
      if (w > 10) { r = 5 + (w - 10) * 0.5; } else { r = 5; }
    }
  } else {
    if (w > 10) { r = 15 + (w - 10) * 1.5; } else { r = 15; }
  }
  return r;
}

Step 1, characterization tests. Before touching anything, record what it returns:

ship(4, "US", false)   // 5
ship(14, "US", false)  // 7
ship(14, "US", true)   // 0
ship(4, "FR", true)    // 15   (premium does not apply abroad, today)
ship(14, "FR", false)  // 21

Step 2, rename for meaning. w becomes weightKg, c becomes country, p becomes isPremium. Run the tests.

Step 3, name the numbers. Pull the rates into one place:

const RATES = {
  domestic: { base: 5, perExtraKg: 0.5 },
  international: { base: 15, perExtraKg: 1.5 },
};
const FREE_WEIGHT_KG = 10;

Step 4, extract the repeated rule and flatten with guard clauses.

function weightCharge(weightKg, rate) {
  const extra = Math.max(0, weightKg - FREE_WEIGHT_KG);
  return rate.base + extra * rate.perExtraKg;
}

function ship(weightKg, country, isPremium) {
  if (country === "US" && isPremium) return 0;
  const rate = country === "US" ? RATES.domestic : RATES.international;
  return weightCharge(weightKg, rate);
}

Run the tests after each step. All five still pass, so behaviour is unchanged. The new version has no nesting, every number has a name, and the next change ("raise the international base to 18") is one edit in one place.

Notice what we did not do: we did not "fix" the rule that premium does not apply abroad. Maybe it is a bug, maybe it is policy. A refactor keeps it; changing it is a separate, deliberate change with its own test and its own commit.

Common refactorings

SmellRefactoring
Long functionExtract function
Deep nestingGuard clauses (return early)
Magic numberReplace with named constant
Unclear nameRename variable or function
Long parameter listPass one options object
Duplicated logicExtract shared function (when the copies must change together)

Most editors automate rename and extract function safely across files. Use those tools rather than search-and-replace.

Common mistakes

  • Refactoring without tests and "checking by eye".
  • Mixing a refactor and a feature in one commit, so a reviewer cannot tell which line changed behaviour.
  • Fixing odd behaviour mid-refactor without asking whether something depends on it.
  • Refactoring code nobody needs to change. Improve the code you are about to work in.

Recap

  • Refactor = same behaviour, better structure. Tests are what make it safe.
  • No tests? Write characterization tests that record today's behaviour first.
  • Small steps, run tests after each, separate commits for refactors and fixes.

Try it

In the Do tab, refactor a tangled pricing function while a set of characterization checks keeps you honest.

Also in this lesson: