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:

  1. Write tests for the new feature (red)
  2. Implement (green)
  3. 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 a ValueError is 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:

  1. Rename calc to calculate_total
  2. Extract the validation into validate_order
  3. Add type hints

In what order would you do them and why?

See solution

A suggested order:

  1. 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.
  2. 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).
  3. 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_login test uses mock.patch("auth.hash_password") to mock the internal function
  • The test_register test verifies that UserRepository.create is 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 times create was 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

  1. Martin Fowler: Refactoring — The classic catalog of refactorings with examples
  2. Test-Driven Development by Example (Kent Beck) — The book that popularized Red-Green-Refactor
  3. Refactoring Guru — Refactoring patterns with visual examples
  4. Python Type Hints (PEP 484) — The official guide to type hints in Python
  5. Working Effectively with Legacy Code (Michael Feathers) — How to add tests to existing code before refactoring
  6. Clean Code (Robert C. Martin) — Clean code principles applicable to refactoring

Module 4, Capsule 05 — Testing with Claude Code Guide