Skip to content
academia.sh

Lesson 16 / 16

Refactoring Under Test

Finding, through a scan, behavior that silently changed in an extraction step done without a test; measuring a six-test suite's catching power with mutations; and having the strengthened suite turn that same step red.

Contents

In the previous lesson, the green run taken after every step counted as proof that behavior was preserved. That count itself was never tested: do the six tests actually cover the fee calculation’s entire behavior? This lesson first shows behavior that silently changes in an extraction step done without a test, measures that the same six existing tests fail to catch it, turns the test suite’s catching power into a number, and shows the moment a strengthened suite turns that same step red.

The tests have a single job here: behavior preservation. Writing tests, setting up fake dependencies, and letting a test drive the design are topics of the Software Quality and Testing curriculum. Here, the tests answer only one question — do the code before and after refactoring give the same answer to the same inputs?

The starting point is the previous lesson’s ending state.

// tariff.mjs — previous lesson's end state
export const TIER = [
  { maxGrams: 1000, cents: 4990, name: 'small' },
  { maxGrams: 5000, cents: 7490, name: 'medium' },
  { maxGrams: 10000, cents: 9900, name: 'large' },
  { maxGrams: 20000, cents: 12900, name: 'extra-large' },
  { maxGrams: Infinity, cents: 24900, name: 'heavy' },
];

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };

export function weightTier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
// fee.mjs — previous lesson's end state
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountRate(count) {
  return count > DISCOUNT_THRESHOLD ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
// fee.test.mjs — six tests carried over from the previous lesson
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shipmentFee, periodFee } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
const period = (count) => Array.from({ length: count }, () => ({ ...box, grams: 3000, zone: 'B1' }));

test('the tier fee is read from the weight', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B1' }), 7490);
});

test('the zone factor multiplies the base fee', () => {
  assert.equal(shipmentFee({ ...box, grams: 3000, zone: 'B3' }), 11984);
});

test('the minimum fee is applied after the zone multiplication', () => {
  assert.equal(shipmentFee({ ...box, grams: 500, zone: 'B2' }), 6990);
});

test('volumetric weight is used when it exceeds actual weight', () => {
  assert.equal(shipmentFee({ width: 40, length: 30, height: 30, grams: 500, zone: 'B2' }), 16125);
});

test('all shipments in a period past the threshold total at the discounted rate', () => {
  assert.equal(periodFee(period(60)), Math.round(60 * 7490 * 0.88));
});

test('a period under the threshold gets no discount', () => {
  assert.equal(periodFee(period(10)), 10 * 7490);
});

So the comparison can be made, a copy of the pre-refactoring version is kept.

cp fee.mjs fee-previous.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
# tests 6
# pass 6
# fail 0

Behavior That Silently Changes

The change is the same extraction as in the previous lesson: the period discount’s threshold decision gets its own name. While it is being named, the comparison’s direction shifts.

// fee.mjs — extraction: threshold decision extracted as discountApplies
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountApplies(count) {
  return count >= DISCOUNT_THRESHOLD;
}

export function discountRate(count) {
  return discountApplies(count) ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|# (tests|pass|fail))'
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
# tests 6
# pass 6
# fail 0

All six tests are green. In a codebase without tests, this step would have gone unnoticed; in a codebase with tests, it went unnoticed too. The way to see whether the change actually preserved behavior is to compare the two versions on the same inputs.

// diff.mjs — compares the two versions' results on the same inputs
import { periodFee as previous } from './fee-previous.mjs';
import { periodFee as current } from './fee.mjs';

const box = { width: 20, length: 15, height: 10 };
let tried = 0;
const diverging = [];

for (const grams of [500, 3000, 12000]) {
  for (let count = 1; count <= 120; count++) {
    const period = Array.from({ length: count }, () => ({ ...box, grams, zone: 'B1' }));
    tried += 1;
    const a = previous(period);
    const b = current(period);
    if (a !== b) diverging.push(`count=${count} grams=${grams} previous=${a} current=${b}`);
  }
}

console.log('periods tried:', tried);
console.log('results changed:', diverging.length);
console.log('first example:', diverging[0] ?? 'none');
periods tried: 360
results changed: 3
first example: count=50 grams=500 previous=349500 current=307560

Three of the three hundred sixty periods changed, and all three land at the same point: periods of exactly fifty shipments. In domain terms, a contract customer sitting exactly at the threshold gets billed 3,075.60 TL instead of 3,495.00 TL. The green run did not say anything wrong; it did not answer the question nobody asked it. Tests pin down their own inputs, not the whole input space.

Measuring the Protection

The quantity that needs measuring, then, is the test suite’s catching power: how many kinds of behavior change turn the run red? This is measured by applying deliberate, small corruptions to the code and counting the run’s reaction. Each corruption sits at the level of a single character or a single number and is undone after the measurement; these are called mutations.

cp fee.mjs fee-extracted.mjs
cp fee-previous.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
# tests 6
# pass 6
# fail 0
// protection.mjs — measures how many behavior changes the test suite catches
import { readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';

const MUTATION = [
  ['threshold comparison', 'fee.mjs', 'count > DISCOUNT_THRESHOLD', 'count >= DISCOUNT_THRESHOLD'],
  ['minimum fee', 'fee.mjs', 'MINIMUM_CENTS = 6990', 'MINIMUM_CENTS = 6890'],
  ['discount rate', 'fee.mjs', 'DISCOUNT_RATE = 0.88', 'DISCOUNT_RATE = 0.89'],
  ['volume rounding', 'fee.mjs', 'Math.ceil(', 'Math.floor('],
  ['zone factor', 'tariff.mjs', 'B2: 1.25', 'B2: 1.3'],
  ['tier boundary', 'tariff.mjs', 'grams <= t.maxGrams', 'grams < t.maxGrams'],
];

function isRed() {
  try {
    execFileSync('node', ['--test', 'fee.test.mjs'], { stdio: 'ignore' });
    return false;
  } catch {
    return true;
  }
}

let caught = 0;
for (const [name, file, from, to] of MUTATION) {
  const original = readFileSync(file, 'utf8');
  writeFileSync(file, original.replace(from, to));
  const red = isRed();
  writeFileSync(file, original);
  if (red) caught += 1;
  console.log(`${name}: ${red ? 'caught' : 'escaped'}`);
}
console.log(`caught: ${caught}/${MUTATION.length}`);
threshold comparison: escaped
minimum fee: caught
discount rate: caught
volume rounding: escaped
zone factor: caught
tier boundary: caught
caught: 4/6

Four of the six corruptions were caught. The two that escaped do not escape by accident. The threshold comparison escaped because the period tests try 60 and 10 shipments, neither one being the threshold itself. Volume rounding escaped because both test boxes’ volumetric weight comes out as a whole number — 3,000 and 12,000 grams — so rounding up and rounding down give the same result. Both escapes follow the same pattern: the boundary was never tried.

Closing the gap goes through the door the previous lesson’s extractions opened. Extraction had increased the number of testable units; boundary behavior can now be pinned down from those units themselves, not from the outer edge.

cat >> fee.test.mjs <<'SON'

import { effectiveWeight, discountRate } from './fee.mjs';

test('volumetric weight rounds up', () => {
  assert.equal(effectiveWeight({ width: 11, length: 11, height: 11, grams: 100 }), 444);
});

test('a shipment count at the threshold produces no discount', () => {
  assert.equal(discountRate(50), 1);
});
SON
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
node protection.mjs
# tests 8
# pass 8
# fail 0
threshold comparison: caught
minimum fee: caught
discount rate: caught
volume rounding: caught
zone factor: caught
tier boundary: caught
caught: 6/6

Two tests closed the two escaped corruptions. What was gained is not the test count but a ratio: catching power rose from four in six to six in six. This ratio is not a certificate of assurance, it is a measurement; a mutation type not on the list can still escape. The measure’s value lies in turning “are my tests enough” into a question that can be counted.

Same Step, Strengthened Suite

The shifted extraction is now brought back.

cp fee-extracted.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs \
  | grep -E '^ *(ok|not ok|expected:|actual:|# (tests|pass|fail))'
ok 1 - the tier fee is read from the weight
ok 2 - the zone factor multiplies the base fee
ok 3 - the minimum fee is applied after the zone multiplication
ok 4 - volumetric weight is used when it exceeds actual weight
ok 5 - all shipments in a period past the threshold total at the discounted rate
ok 6 - a period under the threshold gets no discount
ok 7 - volumetric weight rounds up
not ok 8 - a shipment count at the threshold produces no discount
  expected: 1
  actual: 0.88
# tests 8
# pass 7
# fail 1

Same change, same code, a different result: the eighth test is red, and the expected and actual values are read straight from the run. The threshold test shows, with a single call, the same deviation the 360-period scan had to search for. The fix is to reverse the shifted comparison.

// fee.mjs — same extraction, with the comparison direction preserved
import { weightTier, zoneFactor } from './tariff.mjs';

const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

export function effectiveWeight(shipment) {
  const volume = Math.ceil((shipment.width * shipment.length * shipment.height) / 3);
  return Math.max(shipment.grams, volume);
}

export function discountApplies(count) {
  return count > DISCOUNT_THRESHOLD;
}

export function discountRate(count) {
  return discountApplies(count) ? DISCOUNT_RATE : 1;
}

export function shipmentFee(shipment) {
  const base = weightTier(effectiveWeight(shipment)).cents;
  return Math.max(Math.round(base * zoneFactor(shipment.zone)), MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  return Math.round(total * discountRate(shipments.length));
}
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
node diff.mjs
# tests 8
# pass 8
# fail 0
periods tried: 360
results changed: 0
first example: none

The run is green and the scan finds zero deviations. The two measures gain their meaning together: the scan says this change preserved behavior across the inputs tried, and the mutation measure says the suite catches all six of its six mutation types. Behavior preservation is not an intention — it is a guarantee expressed in two numbers.

Summary

  • A refactoring step’s claim to preserve behavior is read not from a green run but from two versions giving the same result on the same inputs.
  • One shifted comparison direction left six of six tests green; a scan found deviations in three of 360 periods, all three landing at exactly the threshold.
  • A test suite’s catching power can be measured: of six mutations applied to the code, four were caught, two escaped, and both escapes came from an untested boundary.
  • Boundary behavior was pinned down with two tests, written over the units extraction had produced, and catching power rose from four in six to six in six.
  • In the strengthened suite, the same change turned the eighth test red; the expected and actual values were read directly from the run.
  • The mutation measure is not a certificate of sufficiency; it is a tool that turns “are my tests enough” into a number.

Course Wrap-Up

The Clean Code course covered the internal structure of code from three angles across sixteen lessons.

Naming and Structure turned readability into a measurable quantity: names that state intent, naming that replaces comments, small functions staying at a single level of abstraction, the cognitive cost of indentation and formatting consistency, cyclomatic complexity measured by branch count, and the choice among conditionals, lookup tables, and polymorphism.

Interface and Data Decisions carried the decision into the signature: never passing a null value, splitting boolean flag parameters into two functions, choosing the narrowest visibility, grouping code by its reason to change — that is, by actor — and doing the same task the same way throughout the project.

Simplicity Principles counted the cost of decisions over time: the same information staying in one place, incidental similarity turning into a wrong abstraction, a design evolving in small steps, the cost of a generalization written for a requirement that has not arrived, the extract–move–rename techniques, and measuring behavior preservation.

What the sixteen lessons had in common was tying every claim to a number: files touched, branches, uncovered paths, diverging boundaries, mutations caught. There is one more thing they had in common, left unnamed until the end of this lesson: every decision was made within a single programming style. Functions, constants, modules, and object dictionaries were used without ever being discussed. How the code was written was questioned; the model it was thought in was not.

The fee library was modeled as a set of functions and data tables. The same domain could just as well have been modeled as objects holding behavior and state together, or as a computation of chained transformations in which state never changes. Which of these three models gets chosen is not a clean code question — it is a paradigm question. The next course, Programming Paradigms, takes this up: the modeling difference between object-oriented and functional approaches, the situations where composition gets chosen over inheritance, and the same problem modeled and compared across more than one paradigm.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close