Skip to main content
Engineering/tdd-refactor

TDD Refactor

You want a safe refactor path with test checkpoints.

Use this when you have passing tests and want to improve code quality — remove duplication, improve naming, simplify structure, extract abstractions — without changing behavior. This is the "Refactor" step of the Red-Green-Refactor cycle.

Process

Step 1: Gather context

Ask the engineer:

  1. What code do you want to refactor? (File path, function, class, or module.)
  2. What tests cover this code? (Test file path or test suite name.)
  3. What's bugging you about the current code? (Duplication, naming, complexity, long methods, unclear structure — or "just clean it up.")

Step 2: Verify the safety net

Before proposing any changes:

  1. Read the code to refactor and the covering tests.
  2. Confirm all tests pass. If any test fails, stop — fix the failing test first. Refactoring starts from green.
  3. Assess test coverage of the target code. Flag areas with weak or missing coverage — refactoring there is risky without additional tests.

Step 3: Propose refactoring steps

Analyze the code and propose a sequence of small, independent refactoring steps. Each step should be one of these moves:

  • Rename — variable, function, class, or file for clarity
  • Extract — pull a block into a named function or method
  • Inline — replace a trivial abstraction with its contents
  • Move — relocate code to a more logical home
  • Simplify — reduce conditional complexity, flatten nesting
  • Remove duplication — consolidate repeated patterns
  • Improve types — tighten type signatures, remove any

Present the steps as a numbered list with:

  • What changes
  • Why it improves the code
  • Risk level (safe / low / medium)

Ask the engineer to approve, modify, or skip steps before proceeding.

Step 4: Apply one step at a time

For each approved step:

  1. Make the change.
  2. Run the tests.
  3. If all tests pass — move to the next step.
  4. If any test fails — revert the change immediately. Diagnose whether the test was too tightly coupled to implementation or whether the refactoring changed behavior. Report findings before proceeding.

Never combine multiple refactoring steps into a single change. One step, one test run.

Step 5: Summary

After all steps are applied (or the engineer says "that's enough"), present:

  • What changed (list of refactoring moves applied)
  • What didn't change (behavior, public API, test outcomes)
  • Any remaining opportunities flagged for later

Output location

Apply changes directly to the codebase. Present the summary in the conversation.

Example Output

Input

  • File to refactor: src/pricing/discount.ts — a calculateDiscount() function and two helpers
  • Test suite: tests/pricing/discount.test.ts — 14 passing tests covering all branches
  • What's bugging you: "There's a big nested if/else block that checks customer tier and order size, the same 0.01 multiplier appears four times with no label, and there's a helper called doCalc that nobody can explain"

Output (abbreviated)

TDD Refactor: src/pricing/discount.ts

Safety Net Check ✅

  • All 14 tests passing before any changes
  • Coverage: full branch coverage on calculateDiscount(), applyTierRate(), and doCalc()
  • No risky blind spots — safe to proceed

Proposed Refactoring Steps

#MoveWhat ChangesWhyRisk
1RenamedoCalcapplyTierRatedoCalc communicates nothing; new name matches domain languageSafe
2ExtractPull magic number 0.01 into const PERCENT_MULTIPLIER = 0.01Four inline literals; one named constant clarifies intent and centralizes changeSafe
3Remove duplicationConsolidate four near-identical tier * amount * 0.01 expressions into a single computeRawDiscount(tier, amount) callSame arithmetic repeated per tier branch; extract removes drift riskLow
4SimplifyReplace nested if/else tier block with a lookup map TIER_RATES: Record<CustomerTier, number>34-line if/else becomes a 5-line map lookup; adding a tier no longer requires a new branchLow
5Improve typesReplace tier: string parameter with tier: CustomerTier (existing enum, not currently used here)Prevents invalid tier values at compile time; removes a runtime guard that tests already exerciseLow

Approve, skip, or modify steps before I proceed.


Step 1 Applied — Rename doCalcapplyTierRate

// Before
function doCalc(tier: number, amount: number): number {

// After
function applyTierRate(tier: number, amount: number): number {

✅ 14/14 tests pass. Proceeding.


Step 2 Applied — Extract PERCENT_MULTIPLIER

const PERCENT_MULTIPLIER = 0.01;

All four * 0.01 sites updated. ✅ 14/14 tests pass.


Step 3 Applied — Extract computeRawDiscount()

function computeRawDiscount(tierRate: number, orderAmount: number): number {
  return tierRate * orderAmount * PERCENT_MULTIPLIER;
}

Replaced four duplicate expressions. ✅ 14/14 tests pass.


Step 4 Applied — Replace if/else with TIER_RATES map

const TIER_RATES: Record<CustomerTier, number> = {
  bronze:   5,
  silver:  10,
  gold:    15,
  platinum: 20,
};

export function calculateDiscount(tier: CustomerTier, orderAmount: number): number {
  const rate = TIER_RATES[tier] ?? 0;
  return computeRawDiscount(rate, orderAmount);
}

✅ 14/14 tests pass. 34-line block replaced with 7 lines.


Step 5 Applied — Tighten type signature

// Before
function calculateDiscount(tier: string, orderAmount: number)

// After
function calculateDiscount(tier: CustomerTier, orderAmount: number)

Removed runtime if (!validTiers.includes(tier)) guard — now caught at compile time. ✅ 14/14 tests pass.


Refactor Summary

What changed

  • doCalc renamed to applyTierRate
  • Magic number 0.01 extracted to PERCENT_MULTIPLIER
  • Duplicate arithmetic consolidated into computeRawDiscount()
  • 34-line if/else replaced with TIER_RATES lookup map
  • tier parameter tightened from string to CustomerTier enum

What didn't change

  • All 14 tests pass with identical outcomes
  • Public API signature (callers already passed valid tier strings matching the enum values)
  • Discount amounts for all tier/amount combinations

Remaining opportunities (flagged for later)

  • orderAmount has no validation — negative or zero values return a discount silently; consider a guard or a dedicated OrderAmount value type
  • TIER_RATES is module-level; if rates become configurable, extract to an injected dependency