Module 4: The Complete TDD Workflow
Refactoring with Tests as a Safety Net
Refactoring with Tests as a Safety Net
Capsule overview
When Claude Code implements code that passes your tests, the TDD cycle reaches green. What comes next? Refactoring. Improving the structure, the naming, removing duplication — without changing the behavior. Without tests, every refactoring is a leap into the void: you can break something without knowing it until a user reports a bug in production. With green tests, you have a safety net: if the refactoring changes the behavior, the tests fail immediately. This capsule teaches you to use the tests as a safety net to refactor with confidence — and to guide Claude Code to do the same.
What is refactoring?
Definition: structure without a change in behavior
Refactoring is changing the internal structure of code without changing its observable behavior. The program does exactly the same thing before and after: same inputs, same outputs, same side effects. The only thing that changes is how the code is organized — readability, modularity, naming, removal of duplication.
Before the refactoring: input X → output Y
After the refactoring: input X → output Y (identical)
If the output changes → it's NOT refactoring, it's a feature change or a bug.
The tests define the "behavior"
In TDD, the tests are the specification of the behavior. If the tests pass before the refactoring and keep passing afterwards, the behavior didn't change — the refactoring is safe.
Without tests: "Did I change something by accident?" → You don't know until someone notices
With tests: pytest says PASS → the behavior is preserved
Golden rule: Without tests, refactoring is gambling. With tests, refactoring is engineering.
The refactoring phase in agentic TDD
Where it fits in the Red-Green-Refactor cycle
1. RED → You write a test that fails
2. GREEN → Claude Code implements until the test passes
3. REFACTOR → You improve the code (this capsule)
4. You repeat
The refactoring phase happens only when all the tests pass. If any test is red, don't refactor — get it green first.
The workflow with Claude Code
The ideal case:
You: "Refactor this code keeping all the tests passing. Improve: [naming, structure, DRY, etc.]"
Claude Code refactors
You: run pytest
Result: all the tests pass → a successful refactoring
The case where Claude Code introduces a bug:
Claude Code refactors
You: run pytest
Result: one test fails
You: revert (git checkout or undo)
You: try a different approach or adjust the prompt
The safety net works both ways: it protects your code and tells you when Claude Code got it wrong.
Common refactoring patterns with Claude Code
a) Extract Function
Monolithic code in a single function gets split into smaller functions with clear responsibilities.
Before:
# order_service.py
def process_order(order: dict) -> dict:
# Validation (15 lines)
if "items" not in order or not order["items"]:
raise ValueError("Order must have items")
for item in order["items"]:
if "price" not in item or "qty" not in item:
raise ValueError("Each item must have price and qty")
if item["price"] < 0 or item["qty"] < 1:
raise ValueError("Invalid price or quantity")
# Calculation (10 lines)
subtotal = sum(item["price"] * item["qty"] for item in order["items"])
tax_rate = 0.16
tax = subtotal * tax_rate
total = subtotal + tax
# Formatting (8 lines)
return {
"order_id": order.get("order_id", "N/A"),
"subtotal": round(subtotal, 2),
"tax": round(tax, 2),
"total": round(total, 2),
}
After the refactoring:
# order_service.py
def validate_order(order: dict) -> None:
"""Validates that the order has valid items. Raises ValueError if not."""
if "items" not in order or not order["items"]:
raise ValueError("Order must have items")
for item in order["items"]:
if "price" not in item or "qty" not in item:
raise ValueError("Each item must have price and qty")
if item["price"] < 0 or item["qty"] < 1:
raise ValueError("Invalid price or quantity")
def calculate_total(items: list[dict], tax_rate: float = 0.16) -> tuple[float, float, float]:
"""Returns (subtotal, tax, total)."""
subtotal = sum(item["price"] * item["qty"] for item in items)
tax = subtotal * tax_rate
total = subtotal + tax
return subtotal, tax, total
def format_receipt(order: dict, subtotal: float, tax: float, total: float) -> dict:
"""Formats the receipt with rounded values."""
return {
"order_id": order.get("order_id", "N/A"),
"subtotal": round(subtotal, 2),
"tax": round(tax, 2),
"total": round(total, 2),
}
def process_order(order: dict) -> dict:
validate_order(order)
subtotal, tax, total = calculate_total(order["items"])
return format_receipt(order, subtotal, tax, total)
The tests keep passing because process_order still produces the same output for the same inputs.
b) Rename for clarity
Variables and functions with vague names (data, x, do_stuff) get renamed so the code reads like prose.
# Before
def calc(o):
t = sum(i["p"] * i["q"] for i in o["items"])
return t * 1.16
# After
def calculate_order_total(order: dict) -> float:
subtotal = sum(item["price"] * item["quantity"] for item in order["items"])
return subtotal * 1.16
c) Remove duplication (DRY)
Repeated code gets extracted into a shared function or constant.
# Before: duplication
def get_user_email(user):
if not user or "email" not in user or not user["email"]:
raise ValueError("Invalid user")
return user["email"]
def get_user_name(user):
if not user or "name" not in user or not user["name"]:
raise ValueError("Invalid user")
return user["name"]
# After: DRY
def get_required_field(obj: dict, field: str) -> str:
if not obj or field not in obj or not obj[field]:
raise ValueError("Invalid user")
return obj[field]
def get_user_email(user: dict) -> str:
return get_required_field(user, "email")
def get_user_name(user: dict) -> str:
return get_required_field(user, "name")
d) Simplify conditionals
Complex conditions get extracted into functions with descriptive names or get restructured.
# Before
def can_access(user, resource):
if user and user.get("active") and (user.get("role") == "admin" or (user.get("role") == "editor" and resource.get("owner") == user.get("id"))):
return True
return False
# After
def is_admin(user: dict) -> bool:
return user.get("role") == "admin"
def is_owner(user: dict, resource: dict) -> bool:
return user.get("role") == "editor" and resource.get("owner") == user.get("id")
def can_access(user: dict | None, resource: dict) -> bool:
if not user or not user.get("active"):
return False
return is_admin(user) or is_owner(user, resource)
e) Add type hints
Improving the code's documentation without changing the logic.
# Before
def merge(a, b):
return {**a, **b}
# After
def merge(a: dict[str, object], b: dict[str, object]) -> dict[str, object]:
return {**a, **b}
The refactoring safety protocol
Follow these steps so that every refactoring is safe:
1. Do all the tests pass? → NO → Fix them first, then refactor
→ YES → Continue
2. Make ONE SINGLE refactoring change
3. Run the tests
4. Do they all still pass?
├── YES → commit, next refactoring (or finish)
└── NO → revert, understand why it failed, try another way
Key rules:
- ✅ One change at a time — if the tests fail, you know exactly what caused it
- ✅ Commit after each successful refactoring — a clear point to return to
- ❌ Don't refactor and add features in the same commit
- ❌ Don't do multiple refactorings without running the tests in between
The flow in the terminal
# 1. Verify that everything passes
pytest -v
# 2. Make a single refactoring change (or ask Claude Code to do it)
# 3. Run the tests again
pytest -v
# 4a. If they pass: commit
git add .
git commit -m "refactor: extract validate_email in auth_service"
# 4b. If they fail: revert
git checkout -- auth_service.py
# Then: analyze why it failed, adjust the approach
How to ask Claude Code for a refactoring
Effective prompts
General:
Refactor [function/file] to improve readability. The tests must keep passing.
Extracting logic:
Extract the validation logic into a separate function. Keep all the tests green.
DRY:
Remove the duplication between [func_a] and [func_b]. Create a shared function.
Naming:
Rename the variables and functions for greater clarity. Don't change the behavior.
With context:
Refactor process_order. The tests in test_order_service.py define the contract.
Improve: extract the validation, the calculation and the formatting into separate functions.
A real interaction example
Before (working but messy code):
# auth.py
def register(u):
if not u.get("email") or "@" not in u["email"]:
raise ValueError("bad email")
if not u.get("password") or len(u["password"]) < 8:
raise ValueError("bad password")
h = hashlib.sha256(u["password"].encode()).hexdigest()
return {"email": u["email"], "hash": h}
A prompt to Claude Code:
Refactor the register function in auth.py:
1. Extract the email validation into validate_email
2. Extract the password validation into validate_password
3. Extract the hashing into hash_password
4. Add type hints
The tests in test_auth.py must keep passing. Don't change the behavior.
After (Claude Code refactors):
# auth.py
import hashlib
def validate_email(user: dict) -> None:
if not user.get("email") or "@" not in user["email"]:
raise ValueError("bad email")
def validate_password(user: dict) -> None:
if not user.get("password") or len(user["password"]) < 8:
raise ValueError("bad password")
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def register(user: dict) -> dict:
validate_email(user)
validate_password(user)
hashed = hash_password(user["password"])
return {"email": user["email"], "hash": hashed}
You run pytest test_auth.py -v → everything passes → a safe refactoring.
When NOT to refactor
There are situations where refactoring is counterproductive:
1. The tests are green but fragile
If the tests depend on implementation details (internal function names, the order of calls), they'll break when you refactor even though the behavior is correct. Refactor the tests first so they verify only inputs and outputs.
2. You're about to add a new feature
Don't refactor in the same step where you implement a feature. The correct flow:
- Write tests for the new feature (red)
- Implement (green)
- Refactor (keep it green)
3. The "refactoring" changes the behavior
If your change modifies outputs, error handling or edge cases, it isn't refactoring — it's a new feature or a bug. Write tests for the new behavior and treat the change as such.
4. You don't have tests for that part of the code
Without tests, you have no safety net. First write tests (even test-after for legacy code) and then refactor.
A checklist before refactoring
Use this mental list before every refactoring session:
- ✅ All the tests pass
- ✅ The code is under version control (you can revert)
- ✅ You know what change you're going to make (just one)
- ✅ The tests verify behavior, not implementation
- ❌ There are no features in progress that depend on that untested code
A complete practical example: refactoring user registration
We start from a function that works but is messy. The tests already pass.
Initial code (working but monolithic)
# auth_service.py
import hashlib
import re
def register_user(data):
# Everything in a single function
if not data:
raise ValueError("data required")
email = data.get("email", "").strip().lower()
if not email or not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
raise ValueError("invalid email")
pwd = data.get("password", "")
if len(pwd) < 8:
raise ValueError("password must be at least 8 chars")
if not any(c.isupper() for c in pwd) or not any(c.isdigit() for c in pwd):
raise ValueError("password needs uppercase and digit")
salt = "auth_salt_v1"
hashed = hashlib.sha256((salt + pwd).encode()).hexdigest()
return {"email": email, "password_hash": hashed}
# test_auth_service.py
import pytest
from auth_service import register_user
def test_register_valid_email():
result = register_user({"email": "user@test.com", "password": "SecurePass1"})
assert result["email"] == "user@test.com"
assert "password_hash" in result
assert len(result["password_hash"]) == 64
def test_register_invalid_email_raises():
with pytest.raises(ValueError, match="invalid email"):
register_user({"email": "bad", "password": "SecurePass1"})
def test_register_weak_password_raises():
with pytest.raises(ValueError, match="password must be at least 8 chars"):
register_user({"email": "u@t.com", "password": "short"})
def test_register_password_needs_uppercase_and_digit():
with pytest.raises(ValueError, match="password needs uppercase and digit"):
register_user({"email": "u@t.com", "password": "lowercase1"})
pytest test_auth_service.py -v → 4 passed.
Step 1: Extract the email validation
Prompt: "Extract the email validation into a validate_email function. The tests must keep passing."
# auth_service.py (after step 1)
import hashlib
import re
def validate_email(data: dict) -> str:
if not data:
raise ValueError("data required")
email = data.get("email", "").strip().lower()
if not email or not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
raise ValueError("invalid email")
return email
def register_user(data: dict) -> dict:
email = validate_email(data)
pwd = data.get("password", "")
if len(pwd) < 8:
raise ValueError("password must be at least 8 chars")
if not any(c.isupper() for c in pwd) or not any(c.isdigit() for c in pwd):
raise ValueError("password needs uppercase and digit")
salt = "auth_salt_v1"
hashed = hashlib.sha256((salt + pwd).encode()).hexdigest()
return {"email": email, "password_hash": hashed}
pytest test_auth_service.py -v → 4 passed.
Step 2: Extract the password validation
Prompt: "Extract the password validation into validate_password. Keep the tests green."
def validate_password(data: dict) -> str:
pwd = data.get("password", "")
if len(pwd) < 8:
raise ValueError("password must be at least 8 chars")
if not any(c.isupper() for c in pwd) or not any(c.isdigit() for c in pwd):
raise ValueError("password needs uppercase and digit")
return pwd
def register_user(data: dict) -> dict:
email = validate_email(data)
pwd = validate_password(data)
salt = "auth_salt_v1"
hashed = hashlib.sha256((salt + pwd).encode()).hexdigest()
return {"email": email, "password_hash": hashed}
pytest → 4 passed.
Step 3: Extract the hashing
Prompt: "Extract the hashing into a hash_password function. The tests shouldn't change."
def hash_password(password: str, salt: str = "auth_salt_v1") -> str:
return hashlib.sha256((salt + password).encode()).hexdigest()
def register_user(data: dict) -> dict:
email = validate_email(data)
pwd = validate_password(data)
hashed = hash_password(pwd)
return {"email": email, "password_hash": hashed}
pytest → 4 passed.
Step 4: Rename and add type hints
Prompt: "Rename pwd to password and add complete type hints. Don't change the behavior."
def register_user(data: dict) -> dict:
email = validate_email(data)
password = validate_password(data)
hashed = hash_password(password)
return {"email": email, "password_hash": hashed}
pytest → 4 passed.
Each step was a small change. Each step kept the tests green. The final code is more readable and maintainable.
The example's complete runnable code
So you can run it locally:
# auth_service.py (final refactored version)
import hashlib
import re
def validate_email(data: dict) -> str:
if not data:
raise ValueError("data required")
email = data.get("email", "").strip().lower()
if not email or not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
raise ValueError("invalid email")
return email
def validate_password(data: dict) -> str:
pwd = data.get("password", "")
if len(pwd) < 8:
raise ValueError("password must be at least 8 chars")
if not any(c.isupper() for c in pwd) or not any(c.isdigit() for c in pwd):
raise ValueError("password needs uppercase and digit")
return pwd
def hash_password(password: str, salt: str = "auth_salt_v1") -> str:
return hashlib.sha256((salt + password).encode()).hexdigest()
def register_user(data: dict) -> dict:
email = validate_email(data)
password = validate_password(data)
hashed = hash_password(password)
return {"email": email, "password_hash": hashed}
# test_auth_service.py
import pytest
from auth_service import register_user, validate_email, validate_password, hash_password
def test_register_valid_email():
result = register_user({"email": "user@test.com", "password": "SecurePass1"})
assert result["email"] == "user@test.com"
assert "password_hash" in result
assert len(result["password_hash"]) == 64
def test_register_invalid_email_raises():
with pytest.raises(ValueError, match="invalid email"):
register_user({"email": "bad", "password": "SecurePass1"})
def test_register_weak_password_raises():
with pytest.raises(ValueError, match="password must be at least 8 chars"):
register_user({"email": "u@t.com", "password": "short"})
def test_register_password_needs_uppercase_and_digit():
with pytest.raises(ValueError, match="password needs uppercase and digit"):
register_user({"email": "u@t.com", "password": "lowercase1"})
def test_validate_email_extracted():
assert validate_email({"email": " User@Test.COM "}) == "user@test.com"
def test_hash_password_deterministic():
h1 = hash_password("SecurePass1")
h2 = hash_password("SecurePass1")
assert h1 == h2
pytest test_auth_service.py -v
# 6 passed
Troubleshooting
Problem 1: The tests fail after Claude Code's refactoring
Cause: Claude Code changed the behavior unintentionally, or the tests are coupled to the implementation.
Solution: Review the diff. If Claude Code changed the logic (not just the structure), revert and ask: "Refactor keeping the exact behavior. Don't change the conditions or the calculations." If the tests verify the implementation (e.g. that a function with a certain internal name exists), refactor the tests so they verify only the contract (inputs/outputs).
Problem 2: Claude Code does several refactorings at once and I don't know which one broke the tests
Cause: A single prompt asking for many changes.
Solution: Ask for one refactoring at a time. "Extract only the email validation. Then I'll ask you for the next step."
Problem 3: The tests pass but the refactored code has subtle bugs
Cause: The tests don't cover that case. The refactoring introduced an untested edge case.
Solution: Add a test for the failing case, then fix the code. The refactoring revealed a gap in your coverage — use it as an opportunity to improve the tests.
Problem 4: I don't know which refactoring to do first
Cause: The code has multiple technical debts.
Solution: Prioritize by impact on readability. Start with: (1) very long functions (extract), (2) obvious duplication (DRY), (3) confusing names (rename). One change at a time, tests after each one.
Problem 5: Claude Code "refactors" and changes the public API
Cause: The prompt wasn't explicit about not changing interfaces.
Solution: Include in the prompt: "Don't change the signatures of the public functions. The tests import and call the same functions with the same arguments."
Project Connection
In Module 4's authentication project (A complete feature with TDD), every TDD cycle ends with refactoring:
Cycle 1: test_register_validates_email → implementation → refactoring (extract validate_email)
Cycle 2: test_register_hashes_password → implementation → refactoring (extract hash_password)
Cycle 3: test_register_creates_user → implementation → refactoring (order the dependencies)
...
Refactoring after each green cycle keeps the code clean while you build the feature. Without refactoring between cycles, you'd end up with a pile of code that works but is impossible to maintain. With constant refactoring, you deliver code that works and is readable.
Exercises
Exercise 1: Identify what refactoring is (Easy)
Classify each change as refactoring (yes/no) and justify it:
a) Changing x + x to 2 * x in a calculation function
b) Changing a ValueError message from "invalid" to "Invalid input"
c) Extracting 20 lines of validation into a validate_input function
d) Changing an if to match/case while keeping the same logic
e) Removing a None check because "it never happens"
See solution
- a) Yes. The same output for the same input. A purely structural/mathematical change.
- b) It depends. If the tests verify the exact message with
match="invalid", it's no longer refactoring — you're changing the contract. If the tests only verify that aValueErroris raised, then yes, it's refactoring. - c) Yes. The structure changes, the behavior doesn't.
- d) Yes. The same logic, different syntax.
- e) No. You're changing the behavior (the code no longer handles
None). It's a feature change or a possible bug.
Exercise 2: Extract a function with Claude Code (Easy)
You have this function:
def calculate_shipping(cart):
total = sum(item["price"] * item["qty"] for item in cart["items"])
if total >= 100:
return 0
if cart.get("country") == "MX":
return 50
return 100
Write the prompt you'd give Claude Code to extract the shipping cost calculation into a get_shipping_cost(total, country) function.
See solution
Refactor calculate_shipping: extract the shipping cost logic into a
get_shipping_cost(total: float, country: str | None) -> float function.
Rules:
- total >= 100 → 0
- country == "MX" → 50
- otherwise → 100
calculate_shipping must call get_shipping_cost after calculating the total.
Don't change the behavior. The tests in test_shipping.py must keep passing.
Exercise 3: The safety protocol (Medium)
You have 3 pending refactorings in order_processor.py:
- Rename
calctocalculate_total - Extract the validation into
validate_order - Add type hints
In what order would you do them and why?
See solution
A suggested order:
- Extract the validation first — it's the biggest change and the one with the most risk of breaking something. If it fails, you detect it early.
- Rename afterwards — it's safe if the tests aren't coupled to the function's name (the tests import by module/function name, which can change with a rename).
- Type hints last — it's purely additive, it doesn't change the behavior.
An alternative: if calc is an internal function that the tests don't call directly, the rename can go first. The rule: the most invasive change first (when the tests are green), so you get fast feedback.
Exercise 4: Refactoring step by step (Medium)
Initial code:
def process(items):
r = []
for i in items:
if i.get("active"):
r.append({"id": i["id"], "name": i["name"].upper()})
return r
Do 3 refactorings in order, running hypothetical tests after each one. The tests verify: for [{"id": 1, "name": "a", "active": True}] → [{"id": 1, "name": "A"}].
See solution
Step 1 — Rename the variables:
def process(items: list[dict]) -> list[dict]:
result = []
for item in items:
if item.get("active"):
result.append({"id": item["id"], "name": item["name"].upper()})
return result
Tests: pass.
Step 2 — Extract the item transformation:
def format_active_item(item: dict) -> dict:
return {"id": item["id"], "name": item["name"].upper()}
def process(items: list[dict]) -> list[dict]:
result = []
for item in items:
if item.get("active"):
result.append(format_active_item(item))
return result
Tests: pass.
Step 3 — A list comprehension (optional, more idiomatic):
def format_active_item(item: dict) -> dict:
return {"id": item["id"], "name": item["name"].upper()}
def process(items: list[dict]) -> list[dict]:
return [format_active_item(item) for item in items if item.get("active")]
Tests: pass.
Exercise 5: When NOT to refactor (Medium)
You have green tests but you know that:
- The
test_logintest usesmock.patch("auth.hash_password")to mock the internal function - The
test_registertest verifies thatUserRepository.createis called exactly once
What would you do before refactoring and why?
See solution
The problem: The tests are coupled to the implementation (an internal function's name, the number of calls to a specific method). Any refactoring that renames hash_password, extracts it into another module or changes how UserRepository is used will break the tests even though the behavior is correct.
The action: Refactor the tests first so they're black-box:
test_login: given a password, the result must have a hash (regardless of which function computes it). Or use a known hash and verify the output.test_register: given a valid user, verify that it exists in the database (or in the repo) with the correct data, regardless of how many timescreatewas called.
Once the tests verify behavior and not implementation, refactor the code with confidence.
Exercise 6: A complete prompt for Claude Code (Medium)
You have payment_processor.py with an 80-line process_payment function that validates, calculates taxes, applies discounts and saves to the DB. Write a complete prompt for Claude Code to refactor it in safe steps.
See solution
Refactor process_payment in payment_processor.py.
Goal: split it into smaller functions without changing the behavior.
The steps I want (do only the first one for now):
1. Extract the payment validation (card, amount, etc.) into validate_payment(payment: dict) -> None
2. Afterwards I'll ask you for the next step
Rules:
- The tests in test_payment_processor.py define the contract. They must keep passing.
- Don't change the exceptions or the error messages.
- Keep the same public signatures (process_payment must still exist with the same parameters).
- Add type hints to the new functions.
Explanation: Asking for "only the first step" prevents Claude Code from making too many changes at once. If something fails, you know it was the validation extraction.
Summary
- ✅ Refactoring = changing the structure without changing the behavior
- ✅ The tests define the behavior — if they pass before and after, the refactoring is safe
- ✅ Without tests, refactoring is a risk; with tests, it's engineering
- ✅ The REFACTOR phase only when all the tests are green
- ✅ One change at a time, tests after each one, commit if they pass
- ✅ Useful patterns: extract function, rename, DRY, simplify conditionals, type hints
- ✅ Clear prompts to Claude Code: "Refactor X. The tests must keep passing."
- ❌ Don't refactor if the tests are fragile (refactor the tests first)
- ❌ Don't mix refactoring with new features in the same step
Additional Resources
- Martin Fowler: Refactoring — The classic catalog of refactorings with examples
- Test-Driven Development by Example (Kent Beck) — The book that popularized Red-Green-Refactor
- Refactoring Guru — Refactoring patterns with visual examples
- Python Type Hints (PEP 484) — The official guide to type hints in Python
- Working Effectively with Legacy Code (Michael Feathers) — How to add tests to existing code before refactoring
- Clean Code (Robert C. Martin) — Clean code principles applicable to refactoring
Module 4, Capsule 05 — Testing with Claude Code Guide