Skip to content
academia.sh

Lesson 15 / 16

Refactoring Techniques

Applying the extract, move, and rename techniques to the fee library through their intermediate steps; taking a test run after every step and measuring the steps by coverage, file count, and name-occurrence count.

Contents

The previous lesson discarded the generalization and met the incoming requirement directly. The file that remains works, but carries three flaws: the zone factor table keeps information that belongs to the tariff inside the fee file, the threshold comparison in the period calculation stays unnamed, and the name s does not say it stands for a shipment. This lesson fixes the three flaws with three mechanical techniques — extract, move, and rename — applies each through its intermediate steps, and shows the run output after every step.

The red-green-refactor cycle was established in the Test-Driven Development topic: there, tests drove the design, and the third phase was set aside for tidying up. Here the subject is not the cycle but the techniques themselves, applied in that third phase. No test gets written in this lesson, no expectation changes, and no run turns red; the tests are only the instrument reporting whether behavior was preserved.

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

// tariff.mjs — single source of tier data
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' },
];

export function tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}
// fee.mjs — previous lesson's end state
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

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

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  if (shipments.length <= DISCOUNT_THRESHOLD) return total;
  return Math.round(total * DISCOUNT_RATE);
}
// 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);
});
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

Common Shape of the Three Techniques

All three techniques are made of the same three parts. Precondition: a run exists that will show the change did not alter behavior, and it is green. Intermediate state: the old and the new stand side by side for a while; the code runs and the tests are green, but the structure lives in two places. Removal: the old one is deleted and the intermediate state closes.

The intermediate state is what makes these techniques work. When a change is made in a single move, a red run cannot tell whether the fault is in the new structure or in how the call sites were migrated. Split into steps, each run answers exactly one question.

Extract

The threshold comparison in the period calculation stays unnamed: the line comparing the period length against DISCOUNT_THRESHOLD applies a rule, but does not say the rule’s name. Extraction moves an expression or a block of statements out into a unit with its own name.

The first step writes the new function and touches no call site.

// fee.mjs — extract step 1: new function written, call site untouched
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

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

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

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, MINIMUM_CENTS);
}

export function periodFee(shipments) {
  const total = shipments.reduce((t, s) => t + shipmentFee(s), 0);
  if (shipments.length <= DISCOUNT_THRESHOLD) return total;
  return Math.round(total * DISCOUNT_RATE);
}
node --test --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | grep -E '^# ((tests|pass|fail) |file |fee\.mjs)'
# tests 6
# pass 6
# fail 0
# file       | line % | branch % | funcs % | uncovered lines
# fee.mjs    |  92.31 |   100.00 |   80.00 | 14-15

The run is green, but the intermediate state’s cost shows up in coverage: line coverage dropped from 100 percent to 92.31, function coverage to 80 percent, and lines 14–15 got listed as uncovered. The new function is not called yet — the mark extraction leaves when stopped halfway: a name nobody uses.

The second step migrates the call site and deletes the old expression.

// fee.mjs — extract step 2: call site switched to the new function
import { tier } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

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

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

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * ZONE_FACTOR[s.zone]);
  return Math.max(withZoneFactor, 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 --experimental-test-coverage --test-reporter=tap fee.test.mjs 2>&1 \
  | grep -E '^# ((tests|pass|fail) |file |fee\.mjs)'
# tests 6
# pass 6
# fail 0
# file       | line % | branch % | funcs % | uncovered lines
# fee.mjs    | 100.00 |   100.00 |  100.00 | 

Coverage came back. The period function’s body dropped from four lines to three, and the discount rule can now be tested separately, under its own name. This is extraction’s measurable gain: the number of testable units grew while behavior stayed the same.

Move

The zone factor table sits in the fee file, but it is information belonging to the tariff: a business decision like the weight tiers, changed by the same authority. Moving is done in three steps, because the import relationship concerns two files at once.

The first step creates the new function in the target module; the source file is untouched.

// tariff.mjs — move step 1: function created in the target module
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 tier(grams) {
  return TIER.find((t) => grams <= t.maxGrams);
}

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'factor table tariff.mjs: %s fee.mjs: %s\n' \
  "$(grep -c 'B2: 1.25' tariff.mjs)" "$(grep -c 'B2: 1.25' fee.mjs)"
# tests 6
# pass 6
# fail 0
factor table tariff.mjs: 1 fee.mjs: 1

The table now sits in two files. This is exactly the knowledge duplication from the first lesson, and it is a deliberately produced, temporary state; the next two steps of the move exist to close that duplication.

The second step migrates the call site to the new function. The old constant stays in the file, now unused.

// fee.mjs — move step 2: call site switched to the tariff's function
import { tier, zoneFactor } from './tariff.mjs';

const ZONE_FACTOR = { B1: 1.0, B2: 1.25, B3: 1.6 };
const MINIMUM_CENTS = 6990;
const DISCOUNT_THRESHOLD = 50;
const DISCOUNT_RATE = 0.88;

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

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

export function shipmentFee(s) {
  const withZoneFactor = Math.round(tier(effectiveWeight(s)).cents * zoneFactor(s.zone));
  return Math.max(withZoneFactor, 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)'
printf 'ZONE_FACTOR occurrences in fee.mjs: %s\n' "$(grep -c ZONE_FACTOR fee.mjs)"
# tests 6
# pass 6
# fail 0
ZONE_FACTOR occurrences in fee.mjs: 1

The occurrence count dropping to one is the precondition for the removal step: if a name occurs only in its own declaration, nothing reads it anywhere. The third step deletes the declaration.

grep -v 'ZONE_FACTOR' fee.mjs > temp.mjs && mv temp.mjs fee.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'factor table tariff.mjs: %s fee.mjs: %s\n' \
  "$(grep -c 'B2: 1.25' tariff.mjs)" "$(grep -c 'B2: 1.25' fee.mjs)"
# tests 6
# pass 6
# fail 0
factor table tariff.mjs: 1 fee.mjs: 0

The factor table now lives in a single file. Moving is measured not by line count but by the number of files a piece of information sits in: it dropped from two to one, and whoever changes tariff decisions from here on looks at a single file.

Rename

The move produced a new flaw. The tariff module now has two lookup functions: one by weight, one by zone. The name tier was enough while the module had a single lookup; once the second arrived, it stopped saying which dimension it looked up by. Renaming’s justification is usually born this way — the name does not get worse, its context does.

An exported name concerns every file that imports it. This is why renaming is also three steps, and the first step keeps the old name alive.

// tariff.mjs — rename step 1: new name added alongside the old one
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 const tier = weightTier;

export function zoneFactor(zone) {
  return ZONE_FACTOR[zone];
}
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
# tests 6
# pass 6
# fail 0
old-name occurrences: 3

The second step migrates the call sites to the new name. A second rename happens in the same step: the parameter s becomes shipment. Fitting two renames into one step is not carelessness — it follows from a difference in scope; s is scoped to a single function, nobody outside sees it, and so it needs no intermediate name.

// fee.mjs — rename step 2: call site switched to the new name
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));
}
node --test --test-reporter=tap fee.test.mjs | grep -E '^# (tests|pass|fail)'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
# tests 6
# pass 6
# fail 0
old-name occurrences: 1

It dropped from three to one; the one remaining occurrence is the link keeping the old name alive. The third step deletes it.

grep -v 'export const tier = weightTier;' tariff.mjs > temp.mjs
mv temp.mjs tariff.mjs
node --test --test-reporter=tap fee.test.mjs | grep -E '^ *(ok|# (tests|pass|fail))'
printf 'old-name occurrences: %s\n' "$(cat tariff.mjs fee.mjs | grep -o 'tier' | wc -l | tr -d ' ')"
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
old-name occurrences: 0

The word “tier” stays in the test names, and it should: what appears there is not a code name, it is the domain’s word. Renaming changes a symbol, not the domain’s vocabulary.

The Steps’ Balance Sheet

Step Technique Run Measured
1 extract green function coverage 100% → 80%
2 extract green function coverage 80% → 100%
3 move green factor table 1 file → 2 files
4 move green old constant’s occurrences 2 → 1
5 move green factor table 2 files → 1 file
6 rename green old name’s occurrences 3
7 rename green old name’s occurrences 3 → 1
8 rename green old name’s occurrences 1 → 0

In all eight steps the run finished green, and the test file never changed: six tests, six expectations, zero edits. Two measures moved backward at one step each — the table grew to two files at the third step, coverage dropped at the first. Both are the mark of an intermediate state, and both closed at the next step. This is where doing refactoring step by step earns its meaning: intermediate states are unavoidable, and the only remedy is keeping them short.

Summary

  • Extract, move, and rename share the same three parts: a green precondition run, an intermediate state where old and new stand side by side, and a removal step.
  • Extraction’s intermediate state shows up in coverage; before the call site was migrated, function coverage dropped to 80 percent, line coverage to 92.31 percent, and two lines got listed as uncovered.
  • Moving’s intermediate state is a deliberately produced knowledge duplication; the factor table grew from one file to two, then back to one once the call site moved.
  • The removal step’s precondition is measurable: once the old name’s occurrence count drops to one, the name occurs only in its own declaration.
  • Exported names change in three steps; names scoped to a single function change in one — the difference comes from scope.
  • All eight steps finished green and none of the six tests changed; the techniques changed only structure, not behavior.

Next Step

In this 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? The next lesson shows an extraction step, done without a test, that silently changes behavior; measures that the same six 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close