Module 2: Unit Tests with Claude Code

Generating Unit Tests with Claude Code

Generating Unit Tests with Claude Code

Capsule overview

Claude Code can generate tests. You already know that. But "generate tests" is like saying "write code" — the result depends entirely on how you ask. A generic prompt produces generic tests. A specific prompt produces tests that cover happy path, edge cases, boundary conditions, and error handling.

This capsule is prompt engineering applied to testing. You're going to learn the prompts that produce professional-quality tests with Claude Code, and you'll contrast them with the prompts that produce trivial tests. The difference between the two is the difference between a test suite that gives real confidence and one that gives false security.

By the end, you'll have a repertoire of prompts for generating tests in different contexts — pure functions, functions with side effects, validations, data transformations — and you'll know when each prompt is appropriate.


The Problem: "Write Tests" Isn't Enough

Generic prompt → Generic tests

Imagine you have this function and you ask Claude Code "write tests":

# user_validator.py
def validate_email(email: str) -> bool:
    """Check if email is valid."""
    if not isinstance(email, str):
        raise TypeError("Email must be a string")
    if not email:
        return False
    parts = email.split("@")
    if len(parts) != 2:
        return False
    local, domain = parts
    if not local or not domain:
        return False
    if "." not in domain:
        return False
    return True

Generic prompt:

Write tests for validate_email

Typical result from Claude Code (generic tests):

def test_valid_email():
    assert validate_email("user@example.com") == True

def test_invalid_email():
    assert validate_email("invalid") == False

def test_empty_email():
    assert validate_email("") == False

3 tests. They cover the bare minimum. But there are at least 10 scenarios they do NOT cover:

  • What about "@example.com" (no local part)?
  • What about "user@" (no domain)?
  • What about "user@domain" (no dot in the domain)?
  • What about "user@@example.com" (double @)?
  • What about None as input?
  • What about 123 as input?
  • What about "user@sub.domain.com" (subdomains)?
  • What about spaces in the email?

Specific prompt → Professional tests

Specific prompt:

Write unit tests for validate_email that cover:
1. Happy path: common valid emails
2. Edge cases: empty email, no @, no domain, no local part
3. Boundary conditions: multiple @, dots in domain and local
4. Error handling: non-string inputs (None, int, list)
5. Name each test describing the behavior it verifies

Result from Claude Code (professional tests):

import pytest
from user_validator import validate_email


class TestValidateEmail:
    # Happy path
    def test_standard_email_is_valid(self):
        assert validate_email("user@example.com") == True
    
    def test_email_with_subdomain_is_valid(self):
        assert validate_email("user@sub.domain.com") == True
    
    def test_email_with_dots_in_local_is_valid(self):
        assert validate_email("first.last@example.com") == True
    
    # Edge cases
    def test_empty_string_is_invalid(self):
        assert validate_email("") == False
    
    def test_missing_at_sign_is_invalid(self):
        assert validate_email("userexample.com") == False
    
    def test_missing_domain_is_invalid(self):
        assert validate_email("user@") == False
    
    def test_missing_local_part_is_invalid(self):
        assert validate_email("@example.com") == False
    
    def test_domain_without_dot_is_invalid(self):
        assert validate_email("user@domain") == False
    
    # Boundary conditions
    def test_multiple_at_signs_is_invalid(self):
        assert validate_email("user@@example.com") == False
    
    def test_at_sign_only_is_invalid(self):
        assert validate_email("@") == False
    
    # Error handling
    def test_none_input_raises_type_error(self):
        with pytest.raises(TypeError, match="Email must be a string"):
            validate_email(None)
    
    def test_integer_input_raises_type_error(self):
        with pytest.raises(TypeError, match="Email must be a string"):
            validate_email(123)
    
    def test_list_input_raises_type_error(self):
        with pytest.raises(TypeError, match="Email must be a string"):
            validate_email(["user@example.com"])

13 tests vs 3. Same function, but the specific prompt produced tests that actually validate the complete behavior.


The 5 Prompts for Generating Quality Tests

Prompt 1: The Complete Prompt (pure functions)

For functions that take inputs and return outputs with no side effects:

Write unit tests for [function] that cover:
1. Happy path: normal use cases
2. Edge cases: empty inputs, None, empty strings, empty lists
3. Boundary conditions: limit values, 0, -1, MAX_INT
4. Error handling: incorrect types, invalid inputs
5. Name each test with: test_[what_it_does]_[condition]_[expected_result]
6. Use arrange-act-assert in each test
7. One assert per test (focused)

When to use: Functions like calculate_discount, validate_email, parse_date, format_currency.

Prompt 2: The Context Prompt (a function within a system)

When the function has context Claude Code needs to understand:

Here is [function], which is part of a [domain] system.
The contract is: [describe expected inputs/outputs].
Business rules:
- [Rule 1]
- [Rule 2]

Generate unit tests that verify:
- Each business rule is met
- Rule violations are handled appropriately
- Domain edge cases: [list them if you know them]

When to use: Functions with business rules like apply_discount, calculate_shipping, determine_user_tier.

Prompt 3: The Expansion Prompt (existing tests)

When you already have tests but want to expand coverage:

Here are the existing tests for [function]:
[paste tests]

And here is the implementation:
[paste function]

Which scenarios are NOT covered? Generate additional tests
for: missing edge cases, boundary conditions, and error paths
that the current tests don't cover.

When to use: When you already have a base of tests (like the ones you wrote in spec-first) and want Claude Code to expand it.

Prompt 4: The Adversarial Prompt (discover bugs)

When you want Claude Code to try to break your code:

Here is the implementation of [function]:
[paste function]

Act as an adversarial tester. Your goal is to find inputs
that make this function fail or produce incorrect results.
Generate tests that expose:
- Inputs that cause unhandled exceptions
- Inputs that produce silently incorrect results
- Problematic parameter combinations

When to use: Before considering a function "done." It's especially useful for finding bugs you didn't see.

Prompt 5: The Documentation Prompt (tests as docs)

When you want tests that serve as documentation of the behavior:

Generate tests for [function] organized as living documentation:

class TestFunctionName:
    # --- Main behavior ---
    # Tests that document what the function normally does
    
    # --- Validation rules ---
    # Tests that document which inputs it rejects and why
    
    # --- Edge cases ---
    # Tests that document behavior at the boundaries
    
Each test name should read like an English sentence that
describes the function's contract.

When to use: When you want pytest -v to generate output that anyone can read to understand the system.


Prompt Engineering for Tests: Practical Rules

Rule 1: Specify the test categories

# ❌ Vague:
"Write tests"

# ✅ Specific:
"Write tests that cover: happy path, edge cases, error handling"

Rule 2: Ask for descriptive naming

# ❌ No guidance:
"Generate tests for parse_date"

# ✅ With a naming convention:
"Generate tests with names that describe behavior:
 test_parse_date_iso_format_returns_datetime
 test_parse_date_invalid_string_raises_value_error"

Rule 3: Give examples of domain edge cases

# ❌ Generic:
"Include edge cases"

# ✅ Specific to the domain:
"Edge cases for a price calculator:
 - price = 0
 - negative price
 - discount > 100%
 - discount = 0%
 - currency with more than 2 decimals"

Rule 4: Ask for one assert per test

# ❌ Unfocused tests:
"Write tests for the user model"

# ✅ Focused tests:
"Write tests with a single assert per test.
 Each test verifies ONE specific behavior."

Rule 5: State the desired pattern

# ❌ No structure:
"Generate tests for the function"

# ✅ With structure:
"Use the arrange-act-assert pattern in each test.
 Organize in a TestFunctionName class."

Complete Example: From Function to Test Suite

Let's look at the complete workflow with a more substantial function.

The function to test

# pricing.py
from datetime import datetime, time


def calculate_price(
    base_price: float,
    quantity: int,
    discount_percent: float = 0,
    is_member: bool = False,
    order_time: time = None,
) -> dict:
    """
    Calculate final price with discounts and surcharges.
    
    Rules:
    - Base discount applied to subtotal
    - Members get additional 5% off
    - Orders between 22:00-06:00 get 10% night surcharge
    - Minimum final price is 0 (no negative prices)
    """
    if base_price < 0:
        raise ValueError("Base price cannot be negative")
    if quantity < 1:
        raise ValueError("Quantity must be at least 1")
    if not 0 <= discount_percent <= 100:
        raise ValueError("Discount must be between 0 and 100")
    
    subtotal = base_price * quantity
    
    discount_amount = subtotal * (discount_percent / 100)
    after_discount = subtotal - discount_amount
    
    if is_member:
        member_discount = after_discount * 0.05
        after_discount -= member_discount
    
    if order_time:
        night_start = time(22, 0)
        night_end = time(6, 0)
        if order_time >= night_start or order_time < night_end:
            night_surcharge = after_discount * 0.10
            after_discount += night_surcharge
    
    final_price = max(0, after_discount)
    
    return {
        "subtotal": round(subtotal, 2),
        "discount_applied": round(discount_amount, 2),
        "final_price": round(final_price, 2),
        "is_member": is_member,
    }

The prompt to Claude Code

Generate professional unit tests for calculate_price in pricing.py.

Business rules to verify:
1. Base discount is applied to the subtotal
2. Members get an additional 5%
3. Orders between 22:00-06:00 have a 10% night surcharge
4. The minimum final price is 0

Test categories:
- Happy path: basic calculations with no discounts or membership
- Discounts: correct discounts (10%, 50%, 100%)
- Membership: an additional 5% discount for members
- Night surcharge: night surcharge between 22:00-06:00
- Validation: errors for invalid inputs
- Edge cases: price 0, quantity 1, discount 0% and 100%

Pattern: arrange-act-assert. One assert per test.
Naming: test_[description_of_the_behavior]

What Claude Code generates

import pytest
from datetime import time
from pricing import calculate_price


class TestCalculatePriceBasic:
    """Happy path: basic calculations"""
    
    def test_single_item_no_discount(self):
        result = calculate_price(base_price=100, quantity=1)
        assert result["final_price"] == 100.0
    
    def test_multiple_items_no_discount(self):
        result = calculate_price(base_price=50, quantity=3)
        assert result["subtotal"] == 150.0
    
    def test_subtotal_equals_price_times_quantity(self):
        result = calculate_price(base_price=25, quantity=4)
        assert result["final_price"] == 100.0


class TestCalculatePriceDiscount:
    """Discounts applied to the subtotal"""
    
    def test_ten_percent_discount(self):
        result = calculate_price(base_price=100, quantity=1, discount_percent=10)
        assert result["final_price"] == 90.0
    
    def test_fifty_percent_discount(self):
        result = calculate_price(base_price=100, quantity=1, discount_percent=50)
        assert result["final_price"] == 50.0
    
    def test_hundred_percent_discount_gives_zero(self):
        result = calculate_price(base_price=100, quantity=1, discount_percent=100)
        assert result["final_price"] == 0.0
    
    def test_zero_percent_discount_no_change(self):
        result = calculate_price(base_price=100, quantity=1, discount_percent=0)
        assert result["final_price"] == 100.0
    
    def test_discount_applied_to_subtotal_not_unit_price(self):
        result = calculate_price(base_price=100, quantity=2, discount_percent=10)
        assert result["discount_applied"] == 20.0


class TestCalculatePriceMembership:
    """Additional 5% discount for members"""
    
    def test_member_gets_additional_five_percent(self):
        result = calculate_price(base_price=100, quantity=1, is_member=True)
        assert result["final_price"] == 95.0
    
    def test_member_discount_stacks_with_base_discount(self):
        result = calculate_price(base_price=100, quantity=1, discount_percent=10, is_member=True)
        assert result["final_price"] == pytest.approx(85.5)
    
    def test_non_member_no_extra_discount(self):
        result = calculate_price(base_price=100, quantity=1, is_member=False)
        assert result["final_price"] == 100.0
    
    def test_result_includes_membership_status(self):
        result = calculate_price(base_price=100, quantity=1, is_member=True)
        assert result["is_member"] == True


class TestCalculatePriceNightSurcharge:
    """10% night surcharge between 22:00-06:00"""
    
    def test_order_at_23_gets_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=time(23, 0))
        assert result["final_price"] == 110.0
    
    def test_order_at_3am_gets_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=time(3, 0))
        assert result["final_price"] == 110.0
    
    def test_order_at_10am_no_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=time(10, 0))
        assert result["final_price"] == 100.0
    
    def test_boundary_22_00_gets_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=time(22, 0))
        assert result["final_price"] == 110.0
    
    def test_boundary_06_00_no_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=time(6, 0))
        assert result["final_price"] == 100.0
    
    def test_no_order_time_means_no_surcharge(self):
        result = calculate_price(base_price=100, quantity=1, order_time=None)
        assert result["final_price"] == 100.0


class TestCalculatePriceValidation:
    """Input validation"""
    
    def test_negative_price_raises_error(self):
        with pytest.raises(ValueError, match="Base price cannot be negative"):
            calculate_price(base_price=-10, quantity=1)
    
    def test_zero_quantity_raises_error(self):
        with pytest.raises(ValueError, match="Quantity must be at least 1"):
            calculate_price(base_price=100, quantity=0)
    
    def test_negative_quantity_raises_error(self):
        with pytest.raises(ValueError, match="Quantity must be at least 1"):
            calculate_price(base_price=100, quantity=-1)
    
    def test_discount_over_100_raises_error(self):
        with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
            calculate_price(base_price=100, quantity=1, discount_percent=150)
    
    def test_negative_discount_raises_error(self):
        with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
            calculate_price(base_price=100, quantity=1, discount_percent=-10)


class TestCalculatePriceEdgeCases:
    """Edge cases"""
    
    def test_zero_base_price(self):
        result = calculate_price(base_price=0, quantity=5)
        assert result["final_price"] == 0.0
    
    def test_minimum_final_price_is_zero(self):
        result = calculate_price(base_price=0, quantity=1, discount_percent=100)
        assert result["final_price"] == 0.0
    
    def test_all_modifiers_combined(self):
        result = calculate_price(
            base_price=100, quantity=2,
            discount_percent=10, is_member=True,
            order_time=time(23, 0)
        )
        assert result["final_price"] == pytest.approx(188.1)

30 tests organized by behavior. Compare this to the 3 tests from the generic prompt.


Project Connection

In the Generated unit test suite (this module's project):

  • You'll use these prompts to generate tests for a complete utilities module
  • You'll iterate with Claude Code: first prompt → you evaluate → expansion prompt → you evaluate → professional suite
  • You'll apply the adversarial prompt to discover edge cases that neither you nor Claude Code saw on the first pass

The prompt is the tool — the quality of the prompt determines the quality of the tests.


Troubleshooting

Problem 1: Claude Code generates tests that are too simple

Cause: The prompt is generic ("write tests for X").

Solution: Use Prompt 1 (Complete) specifying categories: happy path, edge cases, boundary conditions, error handling.

Problem 2: Claude Code generates tests that don't run

Cause: Claude Code doesn't have context about imports or dependencies.

Solution: Always include the complete file of the function (with imports) as context. If the function depends on other modules, include the relevant interfaces.

Problem 3: Claude Code generates tests with incorrect asserts

Cause: Claude Code miscalculated the expected result. It happens with complex logic.

Solution: Run the tests. If they fail due to an incorrect assert (not a bug in the code), give the output to Claude Code: "This test has the wrong expected value. The code returns X but the test expects Y. What's the correct value according to the business logic?"

Problem 4: All the tests pass on the first try

Cause: If all the tests pass immediately, it can be a sign of trivial tests.

Solution: Use Prompt 4 (Adversarial) to try to break the code. If Claude Code can't find a way to break it, the implementation is probably robust. If it finds inputs that cause failures, you have new tests to add.


Exercises

Exercise 1: Generic vs specific prompt (Easy)

Give this function to Claude Code with a generic prompt ("write tests") and then with Prompt 1 (Complete). Compare the results.

def clamp(value: float, min_val: float, max_val: float) -> float:
    """Clamp value between min and max."""
    if min_val > max_val:
        raise ValueError("min_val must be <= max_val")
    return max(min_val, min(max_val, value))
See solution

The generic prompt generates ~3 tests:

def test_clamp_within_range():
    assert clamp(5, 0, 10) == 5

def test_clamp_below_min():
    assert clamp(-5, 0, 10) == 0

def test_clamp_above_max():
    assert clamp(15, 0, 10) == 10

Prompt 1 (Complete) generates ~10 tests:

def test_value_within_range_unchanged(self):
    assert clamp(5, 0, 10) == 5

def test_value_below_min_clamped_to_min(self):
    assert clamp(-5, 0, 10) == 0

def test_value_above_max_clamped_to_max(self):
    assert clamp(15, 0, 10) == 10

def test_value_equals_min(self):
    assert clamp(0, 0, 10) == 0

def test_value_equals_max(self):
    assert clamp(10, 0, 10) == 10

def test_min_equals_max(self):
    assert clamp(5, 5, 5) == 5

def test_negative_range(self):
    assert clamp(0, -10, -1) == -1

def test_float_values(self):
    assert clamp(0.5, 0.0, 1.0) == 0.5

def test_min_greater_than_max_raises_error(self):
    with pytest.raises(ValueError, match="min_val must be <= max_val"):
        clamp(5, 10, 0)

def test_very_large_values(self):
    assert clamp(1e15, 0, 1e10) == 1e10

Explanation: The specific prompt produced boundary conditions (value == min, value == max, min == max), negative ranges, floats, and the error case — all missing in the generic version.

Exercise 2: Adversarial prompt (Easy)

Use Prompt 4 (Adversarial) with Claude Code on this function. What does it find?

def safe_divide(a, b, default=0):
    """Divide a by b, return default if b is zero."""
    if b == 0:
        return default
    return a / b
See solution

The adversarial prompt should reveal:

def test_string_inputs_cause_type_error():
    # There's no type validation
    with pytest.raises(TypeError):
        safe_divide("10", "2")

def test_none_divisor_not_caught():
    # b=None is not 0, but causes TypeError in the division
    with pytest.raises(TypeError):
        safe_divide(10, None)

def test_infinity_divisor():
    # Dividing by infinity gives 0.0 (is that the desired behavior?)
    assert safe_divide(10, float('inf')) == 0.0

def test_nan_divisor():
    # NaN != 0, so it doesn't use default, but returns NaN
    import math
    result = safe_divide(10, float('nan'))
    assert math.isnan(result)  # Is this what we want?

def test_zero_divided_by_zero_uses_default():
    assert safe_divide(0, 0) == 0  # Uses default, but is that correct?

def test_default_can_be_any_type():
    # default isn't validated — it can be a string, None, list...
    assert safe_divide(10, 0, "N/A") == "N/A"

Explanation: The adversarial prompt reveals that safe_divide doesn't validate types, doesn't handle None, infinity, or NaN, and the default can be any type. Each finding is a potential bug in production.

Exercise 3: Write a prompt for your function (Medium)

You have a function format_phone(number: str) -> str that formats phone numbers. Write the prompt you'd give Claude Code to generate professional tests. Include the 5 categories and at least 3 domain-specific edge cases.

See solution
Generate professional unit tests for format_phone(number: str) -> str.

The function formats phone numbers to the format (XXX) XXX-XXXX.
It accepts formats: "1234567890", "123-456-7890", "(123) 456-7890", "123.456.7890"

Categories:
1. Happy path: common valid input formats
2. Edge cases:
   - Empty string
   - Number with extra spaces
   - Number with a country code (+1)
   - Number with an extension (x1234)
3. Boundary conditions:
   - Exactly 10 digits
   - 9 digits (invalid)
   - 11 digits (invalid or with a country code)
4. Error handling:
   - None input
   - Non-string input (int)
   - Letters in the number
5. Output format:
   - Always returns "(XXX) XXX-XXXX"
   - Whitespace is stripped before processing

Naming: test_format_phone_[condition]_[result]
Pattern: arrange-act-assert
One assert per test

Explanation: This prompt gives domain context (accepted formats, output format), domain-specific edge cases from the phone realm (country codes, extensions), and a clear structure.

Exercise 4: Iterate with Claude Code (Medium)

Generate tests for calculate_price (the function in this capsule) using Prompt 1. Then evaluate the generated tests and write an expansion prompt (Prompt 3) for the gaps you find.

See solution

Step 1: You use Prompt 1, Claude Code generates ~15 tests.

Step 2: You evaluate and find gaps:

  • It doesn't test the combination of membership + discount + night surcharge
  • It doesn't test that round() works correctly with values that have many decimals
  • It doesn't test base_price = 0.01 (minimum practical price)

Step 3: Expansion prompt:

The existing tests cover: basic, discount, membership, night, validation.

Gaps found:
1. There's no test that combines the 3 modifiers (discount + member + night)
2. There's no decimal-precision test (0.01 * 3 with a discount)
3. There's no test for a very low base price (0.01)

Generate additional tests ONLY for these 3 gaps.

Step 4: Claude Code generates exactly the 3 missing tests.

Explanation: The iteration workflow (generate → evaluate → expand) produces more complete suites than trying to generate everything at once.


Summary

In this capsule you learned:

  • ✅ "Write tests" produces generic tests — specific prompts produce professional tests
  • ✅ 5 prompts for different contexts: Complete, Context, Expansion, Adversarial, Documentation
  • ✅ 5 prompt-engineering rules for tests: categories, naming, edge cases, focused asserts, desired pattern
  • ✅ The iteration workflow: generate → evaluate → expand → professional suite
  • ✅ The adversarial prompt discovers bugs that neither you nor Claude Code saw on the first pass
  • ✅ The quality of the prompt directly determines the quality of the tests

Next capsule: Pytest patterns — Arrange-Act-Assert in depth.


Additional Resources

  1. pytest: Writing Tests - Official guide on assertions in pytest
  2. Anthropic: Prompt Engineering Guide - Prompt-engineering principles applicable to testing
  3. Test Categories by Martin Fowler - Categorization of test types
  4. Google Testing Blog - Google's testing practices
  5. Property-Based Testing Overview - Hypothesis for automatic edge case discovery (Module 5)

Module 2, Capsule 02 — Testing with Claude Code Guide The prompt determines the quality of the test