Skip to content

Refactoring safely

Change how code is built or what it does, never both at once: green refactor steps first, then each behaviour change in its own commit.

Predict first

Before you read: predict

A payments ledger charges a late fee. The code and its tests today:

// ledger/late-fee.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lateFee } from './late-fee.mjs';

test('10 days late: no fee', () => assert.equal(lateFee(10, 5000), 0));
test('20 days late: no fee', () => assert.equal(lateFee(20, 5000), 0));
test('25 days late, small balance: no fee', () => assert.equal(lateFee(25, 800), 0));
test('31 days late: flat fee', () => assert.equal(lateFee(31, 5000), 1500));
test('45 days late: fee capped at the balance', () => assert.equal(lateFee(45, 900), 900));

Rita has two changes to make. One is a tidy-up that names the two numbers and caps the fee with Math.min:

// ledger/late-fee.mjs (tidied)
const GRACE_DAYS = 30;
const FLAT_FEE_PENCE = 1500;

export function lateFee(daysLate, balancePence) {
  const fee = daysLate > GRACE_DAYS ? FLAT_FEE_PENCE : 0;
  return Math.min(fee, balancePence);
}

The other is finance's new rule: the grace period drops from 30 days to 14, which changes what two of the five tests expect. The team has two rules. Every commit passes the tests as they stand in that commit. And if finance drops the 14-day rule next quarter, a git revert of one commit on main must apply cleanly, bring back 30 days and keep the named constants.

Which commit plan meets both rules?

Code for this question
// ledger/late-fee.mjsexport function lateFee(daysLate, balancePence) {  var fee = 0;  if (daysLate > 30) fee = 1500;  if (fee > balancePence) fee = balancePence;  return fee;}
Pick one

The 30-second version

A refactor changes how code is built and keeps what it does: every input gives the same output, so the tests stay green without edits. Take small steps, run the tests after each, and undo a step that goes red. Refactor first, then make each behaviour change in its own commit, which its test edits describe and one revert removes.

Change how code is built or what it does, never both at once: green refactor steps first, then each behaviour change in its own commit.

How it works

Step 1 of 12

Same behaviour. A refactor changes how code is built, never what it does: every input still gives the same output or error.
In the kitchen
  • new planks and fastenings, and she floats, steers and carries as before → a refactor: how it is built changes, what it does stays (steps 1–2)

Analogy: Re-planking an old fishing boat (boat repair)

A wooden-boat yard is re-planking an old fishing boat. When the job is done she must float, steer and carry the same catch as before.

At the boatyard In code
new planks and fastenings, and she floats, steers and carries as before a refactor: how it is built changes, what it does stays (steps 1–2)
one plank, a hose test on the slip, then the next plank small steps, the tests after each (step 4)
a plank that weeps comes off and is fitted again, never caulked over undo the red step (step 5)
the owner's bigger engine goes on a job card of its own, fitted after the planking is signed off the behaviour change in its own commit, after the refactor (steps 7, 9)
she sits low at the stern, and the owner knows which job did it; the engine comes out, the planks stay one revert removes the behaviour change alone (step 8)
stronger engine bearers first, which change nothing about how she sails today, so the new engine bolts straight in make the change easy, then make the easy change (steps 10–11)

In real terms: each commit either changes how code is built, with the tests untouched, or changes one behaviour, with the test edits that describe it.

Breaks down when: a hose test wets every seam, while tests check only the inputs they pin (step 3). And the yard can lift out an engine whatever was done since; git reverts a commit cleanly only while no later commit rewrote its lines (step 9).

Contrasting case: a plank holed by a rock is replaced because she leaks. That repair changes what she does, so it is a fix with its own card, not part of the re-planking.

Examples, run for real

4.1 ExamHall EXH-2530: the results export's percentages

The results export's formatPercent(points, max) has five characterisation tests that pin what it prints today; how to write such tests belongs to judgment-legacy-code-seams. MR !2830 tidies it: a named cap and one Math.min. MR !2831, separately, switches to half-up rounding and shows 0 out of 0 as 0.0.

4.1 EXH-2530 legacy.mjs today: 5 of 5 characterisation tests pass
4.1 MR !2830 refactored.mjs, test file untouched: 5 of 5 pass
4.1 MR !2831 fixed.mjs on the same tests: 2 of 5 pass · fails formatPercent(51, 80) is 63.7 · formatPercent(41, 80) is 51.2 · formatPercent(0, 0) is NaN
4.1 MR !2831 with its three expected values updated (63.7 -> 63.8, 51.2 -> 51.3, NaN -> 0.0): 5 of 5 pass

MR !2830 changes no output, so all five tests pass unedited (steps 1–2). MR !2831 fails exactly three, and updates those three expected values in the same commit: that test diff is its release note (step 7).

4.2 Payments ledger: what review sees

interest() gets a tidy-up and a rounding change, from rounding down to the nearest penny: once as one commit, once as two.

4.2 one commit "refactor: tidy interest()": interest.mjs +4 -3 · interest.test.mjs +2 -2 · 5 of 5 pass
4.2 two commits, 1 "refactor: name the daily rate": interest.mjs +4 -3 · test file untouched · 5 of 5 pass
4.2 two commits, 2 "interest: round to the nearest penny": interest.mjs +1 -1 · interest.test.mjs +2 -2 · 5 of 5 pass

In one commit the rounding is one changed line among the tidy-up's; split, it is the whole diff of its commit, beside the two test values it moves (steps 6–7).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

The catalog package exports discounted, which other teams' code imports:

// pricing/discount.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { discounted } from './index.mjs';

test('10% off', () => assert.equal(discounted(1000, 10), 900));
test('15% off', () => assert.equal(discounted(999, 15), 849));
test('100% off', () => assert.equal(discounted(500, 100), 0));
test('nothing off', () => assert.equal(discounted(1200, 0), 1200));
test('33% off', () => assert.equal(discounted(250, 33), 168));

An MR titled "refactor: tidy discounted()" has four commits, and the five tests passed after each:

  1. if (p < 0) p = 0; becomes p = Math.max(p, 0);
  2. the first line becomes export function discounted(pricePence, percentOff = 0) {
  3. the function moves, unchanged, into a new pricing/discount.mjs, and pricing/index.mjs becomes export { discounted } from './discount.mjs';
  4. in the test file, '10% off' becomes 'ten percent off a 1000p item'

Which commit is not a refactoring?

Code for this question
// pricing/index.mjsexport function discounted(pricePence, percentOff) {  var p = pricePence - (pricePence * percentOff) / 100;  if (p < 0) p = 0;  return Math.round(p);}
Pick one

Question 2 of 6

Ola is refactoring a streaming app's watchTimeLabel in small steps, running its six tests after each. Her last commit holds the code from before step 1:

// player/watch-time.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { watchTimeLabel } from './watch-time.mjs';

test('0 s', () => assert.equal(watchTimeLabel(0), '0 min'));
test('90 s', () => assert.equal(watchTimeLabel(90), '1 min'));
test('600 s', () => assert.equal(watchTimeLabel(600), '10 min'));
test('3599 s', () => assert.equal(watchTimeLabel(3599), '59 min'));
test('3660 s', () => assert.equal(watchTimeLabel(3660), '1 h 01 min'));
test('7325 s', () => assert.equal(watchTimeLabel(7325), '2 h 02 min'));

The tests passed after each of steps 1–3, which she has not committed. After step 3 the file reads:

// player/watch-time.mjs (after step 3)
const pad2 = (n) => String(n).padStart(2, '0');

export function watchTimeLabel(seconds) {
  const totalMinutes = Math.floor(seconds / 60);
  const hours = Math.floor(totalMinutes / 60);
  const minutes = totalMinutes % 60;
  if (hours > 0) return `${hours} h ${pad2(minutes)} min`;
  return `${minutes} min`;
}

Step 4 replaces the three const lines with two:

  const hours = Math.floor(seconds / 3600);
  const minutes = Math.round((seconds % 3600) / 60);

Now the 90 s and 3599 s tests fail.

Which move keeps steps 1–3 and keeps the session a refactoring?

Code for this question
// player/watch-time.mjsexport function watchTimeLabel(seconds) {  var m = Math.floor(seconds / 60);  var h = Math.floor(m / 60);  m = m % 60;  if (h > 0) return h + ' h ' + String(m).padStart(2, '0') + ' min';  return m + ' min';}
Pick one

Question 3 of 6

A chat app's renderMessage(text) walks the text once, character by character, escaping HTML and turning @mentions into links in the same loop, and its 14 tests pass. Product now wants :shortcode: emoji, and every way Kai tries to fit them into the loop breaks the mention handling.

Which plan holds up?

Pick one

Question 4 of 6

Sam is moving a ticket shop's booking-fee code out of checkout.mjs into a module of its own, one small step at a time. Halfway through, he sees that the fee on a £13.10 ticket, 2.5% or 32.75p, is rounded down to 32p, where the box office's rule says the nearest penny.

He says: "It's one line; I'll fix it in this refactor commit." Which reply holds up?

Pick one

Question 5 of 6

A box office's seat holds. git log --oneline on main, newest first:

7465d89 feat: show a countdown on held seats
10fc1a5 refactor: tidy holdExpiresAt (also holds 7 -> 10 minutes)
8cba506 holds: expire a seat hold after 7 minutes

The middle commit named the constants, changed the hold from 7 to 10 minutes and moved the two hold tests from 420 to 600 seconds. The newest one exported HOLD_MINUTES, added secondsLeft just below holdExpiresAt, and a seat-map countdown that imports both. holds/hold-expiry.mjs on main now reads:

The box office now wants 7-minute holds back. The countdown and the tidied code must stay, and every test must pass.

What does the team do?

Code for this question
// holds/hold-expiry.mjsexport const HOLD_MINUTES = 10;const SECONDS_PER_MINUTE = 60; export function holdExpiresAt(startedAtSec) {  return startedAtSec + HOLD_MINUTES * SECONDS_PER_MINUTE;}export function secondsLeft(startedAtSec, nowSec) {  return Math.max(0, holdExpiresAt(startedAtSec) - nowSec);}
Pick one

Question 6 of 6

A ledger's statement total:

Finance wants a loyalty credit taken off each statement, and a statement must never show less than 0. Pia plans two commits. The second will change only the body of a new function, afterCredits(pence, statement), to return pence - statement.creditPence;.

Which first commit changes no total today and lets that second commit keep finance's rule?

Code for this question
// ledger/statement.mjsexport function statementTotal(statement) {  let total = 0;  for (const row of statement.rows) total += row.pence;  return total < 0 ? 0 : total;}
Pick one

Go deeper

Pro tip
  1. Before you push a refactor branch, run its tests at every commit, not only at the tip: git rebase -x "node --test" main stops at the first commit whose tests fail. When: a branch of several refactor steps is about to be reviewed (steps 4, 7). Verify (RAN, tips.mjs, on a three-commit branch whose second commit breaks one test and whose third repairs it):
    tip 1 node --test at the branch tip only: 4 of 4 pass
    tip 1 git rebase -x "node --test" main: exit 1, stopped after "refactor: name the low-stock limit" (commit 2 of 3)
Source

Google's guide and git's manual pages change with releases, so their claims are labelled as of 2026-10-02; every git behaviour this lesson grades is also run on git 2.52.0.

  • Kent Beck, post on X, 26 September 2012, the curriculum row's first source: https://x.com/KentBeck/status/250733358307500032 (beck-2012-make-the-change-easy.txt). The page renders its body with script; the post's text is in the static HTML's og:description element: "for each desired change, make the change easy (warning: this may be hard), then make the easy change". The source of step 10; quoted in P3.
  • Martin Fowler, An example of preparatory refactoring (5 January 2015), the row's second source: https://martinfowler.com/articles/preparatory-refactoring-example.html (fowler-2015-preparatory-refactoring.txt). The source of steps 6, 10, 11 and 12 ("By defining apply_ranges with this simple behavior-preserving implementation, as opposed to just leaving it blank, I can continue to run my tests"); quoted in P3 and P4.
  • Google Engineering Practices, Small CLs (as of 2026-10-02): https://google.github.io/eng-practices/review/developer/small-cls.html (google-small-cls.txt). Teaching only (step 7): "It's usually best to do refactorings in a separate CL from feature changes or bug fixes." and "Small cleanups such as fixing a local variable name can be included inside of a feature change or bug fix CL, though." It keys no item.
  • git manual, git-revert (as of 2026-10-02): https://git-scm.com/docs/git-revert (git-revert-docs.txt). Teaching only (steps 8–9): "Given one or more existing commits, revert the changes that the related patches introduce, and record some new commits that record them." The probe and D1 run it on git 2.52.0.
  • git manual, git-rebase (as of 2026-10-02): https://git-scm.com/docs/git-rebase (git-rebase-docs.txt). Tip 1: --exec will "Append "exec <cmd>" after each line creating a commit in the final history", and "Any command that fails will interrupt the rebase, with exit code 1."

Checked against the tools and docs as of 2026-10-02.