Why "Test-Driven Refactoring" Matters
Nearly every software project carries legacy code — code that works but is hard to understand, and tends to break something new whenever you touch it. This is one of the most common frustrations in software development.
Claude Code can be your most powerful ally in solving this problem. Rather than rewriting code from scratch, the test-driven refactoring approach uses tests as scaffolding to gradually and safely modernize existing code while preserving its behavior.
Step 1: Write Characterization Tests for the Target Code
The first step in any safe refactoring effort is to create characterization tests — tests that capture current behavior, not necessarily correct behavior. These lock in what the code does before you change anything.
Example Claude Code Prompt
Read the following legacy function and write characterization tests in Vitest/Jest format
that cover its current behavior. Include edge cases like null inputs, empty arrays,
and negative numbers.
[paste your code here]
Claude Code will analyze the function and generate test cases for a wide variety of input patterns. At this stage, coverage matters more than correctness.
Practical Example: Testing a Complex Pricing Function
// Legacy code before refactoring
function calcPrice(items, user, promo) {
let total = 0;
for (let i = 0; i < items.length; i++) {
let p = items[i].price;
if (user && user.isPremium) p = p * 0.9;
if (promo && promo.type === 'percent') p = p * (1 - promo.value / 100);
if (promo && promo.type === 'fixed') p = p - promo.value;
if (p < 0) p = 0;
total += p * items[i].qty;
}
if (user && user.isPremium && total > 10000) total = total * 0.95;
return Math.round(total);
}When you pass this to Claude Code, it generates tests like:
import { describe, it, expect } from 'vitest';
import { calcPrice } from './pricing';
describe('calcPrice', () => {
const baseItems = [{ price: 1000, qty: 2 }, { price: 500, qty: 1 }];
it('standard user with no promo', () => {
expect(calcPrice(baseItems, null, null)).toBe(2500);
});
it('premium user gets 10% discount', () => {
expect(calcPrice(baseItems, { isPremium: true }, null)).toBe(2250);
});
it('percent-off promo applied', () => {
expect(calcPrice(baseItems, null, { type: 'percent', value: 20 })).toBe(2000);
});
it('fixed-amount promo applied', () => {
expect(calcPrice(baseItems, null, { type: 'fixed', value: 300 })).toBe(1900);
});
it('price floors at 0 when fixed promo exceeds price', () => {
const cheapItems = [{ price: 100, qty: 1 }];
expect(calcPrice(cheapItems, null, { type: 'fixed', value: 500 })).toBe(0);
});
it('premium user with total over 10000 gets additional 5% off', () => {
const bigItems = [{ price: 5000, qty: 3 }];
// After 10% premium: 13500 > 10000 → additional 5% off
expect(calcPrice(bigItems, { isPremium: true }, null)).toBe(12825);
});
it('empty items array returns 0', () => {
expect(calcPrice([], null, null)).toBe(0);
});
});Verify all tests pass before moving to the next step.
Step 2: Let Claude Code Plan the Refactoring
Once tests are in place, consult Claude Code on the refactoring strategy:
I want to refactor the calcPrice function above. Please propose improvements
that satisfy these constraints:
- All existing tests must continue to pass
- Apply single responsibility principle by separating concerns
- Migrate to TypeScript
- Extract each transformation step as a separate function
Claude Code will produce a phased plan:
// Step 1: Type definitions
interface Item {
price: number;
qty: number;
}
interface User {
isPremium: boolean;
}
interface Promo {
type: 'percent' | 'fixed';
value: number;
}
// Step 2: Extract each transformation as a pure function
function applyPremiumDiscount(price: number, user: User | null): number {
return user?.isPremium ? price * 0.9 : price;
}
function applyPromo(price: number, promo: Promo | null): number {
if (!promo) return price;
if (promo.type === 'percent') return price * (1 - promo.value / 100);
if (promo.type === 'fixed') return Math.max(0, price - promo.value);
return price;
}
function applyBulkDiscount(total: number, user: User | null): number {
return user?.isPremium && total > 10000 ? total * 0.95 : total;
}
function calcItemTotal(item: Item, user: User | null, promo: Promo | null): number {
let price = applyPremiumDiscount(item.price, user);
price = applyPromo(price, promo);
return price * item.qty;
}
// Step 3: Main function only coordinates
export function calcPrice(
items: Item[],
user: User | null,
promo: Promo | null
): number {
const subtotal = items.reduce(
(sum, item) => sum + calcItemTotal(item, user, promo),
0
);
return Math.round(applyBulkDiscount(subtotal, user));
}The key principle: all original tests must continue to pass after every change.
Step 3: Define Refactoring Rules in CLAUDE.md
Place a CLAUDE.md in the project root to give Claude Code consistent guidelines for your refactoring effort:
# CLAUDE.md — Refactoring Guidelines
## Required Rules
- Always write characterization tests before refactoring
- Confirm all tests pass before proceeding to the next step
- Limit changes to 3 files per commit
- Use TypeScript strict mode
## Separation Principles
- Each function should have exactly one responsibility
- Separate side-effectful code from pure computations
- Express data transformations as pipelines
## Prohibited Actions
- Refactoring without test coverage
- Mixing behavior changes with structural changes in the same commitStep 4: The Incremental Commit Strategy
When working with Claude Code on refactoring, small, incremental commits are the most effective approach:
# Bad: changing everything at once
git commit -m "refactor: rewrite pricing module"
# Good: building up in layers
git commit -m "test: add characterization tests for calcPrice"
git commit -m "types: add TypeScript interfaces for pricing domain"
git commit -m "refactor: extract applyPremiumDiscount pure function"
git commit -m "refactor: extract applyPromo pure function"
git commit -m "refactor: extract applyBulkDiscount pure function"
git commit -m "refactor: simplify calcPrice to coordinate extracted functions"Run npm test after each commit to verify all tests still pass. Use this prompt to instruct Claude Code:
We'll refactor incrementally. After each step, run the tests and confirm they
pass before committing. Only change one function at a time.
Step 5: Improve Testability with Dependency Injection
Legacy code often has external dependencies (databases, APIs, timestamps) hardcoded, making it difficult to test. Claude Code excels at refactoring these patterns:
The following function calls Date.now() and an external API directly.
Refactor it using dependency injection so it can be properly tested with mocks.
[paste your code here]
Claude Code transforms it to accept dependencies as arguments:
// Before (difficult to test)
async function generateReport(userId: string) {
const user = await db.users.findById(userId);
const now = Date.now();
return { user, generatedAt: now };
}
// After (testable with dependency injection)
interface ReportDeps {
findUser: (id: string) => Promise<User>;
getNow: () => number;
}
async function generateReport(
userId: string,
deps: ReportDeps = {
findUser: (id) => db.users.findById(id),
getNow: () => Date.now(),
}
) {
const user = await deps.findUser(userId);
return { user, generatedAt: deps.getNow() };
}
// Tests can now swap in mocks
it('generates a report', async () => {
const mockDeps = {
findUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
getNow: vi.fn().mockReturnValue(1700000000000),
};
const result = await generateReport('1', mockDeps);
expect(result.generatedAt).toBe(1700000000000);
});Using Claude Code's "Explanation Mode"
When the intent of existing code is unclear, Claude Code can help decode it:
Explain what this code does and why it might have been written this way.
Focus especially on the business logic behind each conditional branch.
Claude Code reads between the lines and surfaces the business rules embedded in the code. You can use this output directly as documentation or code comments — a great way to transfer knowledge to future team members.
Looking back: The Claude Code Refactoring Workflow
- Write characterization tests — Lock in existing behavior
- Plan the refactoring — Consult Claude Code for a strategy
- Define rules in CLAUDE.md — Set consistent standards
- Commit incrementally — Small, safe, reversible changes
- Apply dependency injection — Make the code properly testable
The most important principle is don't rewrite everything at once. Use Claude Code as a pair programmer and improve your codebase in small steps within the safety net of tests. That approach leads to sustainable, long-term quality improvements.
Technical debt doesn't disappear in a day — but with Claude Code, you can chip away at it a little every day.