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.