Module 2: Unit Tests with Claude Code

Module Project: Generated Unit Test Suite

Module Project: Generated Unit Test Suite

Project overview

You've learned to generate tests with Claude Code using specific prompts, you've mastered the pytest patterns (AAA, parametrize, fixtures), and you know how to critically evaluate the quality of AI-generated tests. Now it's time to integrate it all into a real project.

You're going to receive a Python utilities module (data_utils.py) with data validation, formatting, and transformation functions. Your job is to use Claude Code to generate a complete suite of unit tests, evaluating the quality of what it generates, refining with specific prompts, and producing a professional test suite that meets the standards you learned.

The focus isn't the complexity of the code under test — it's the quality of the generated tests. A mediocre test suite has 30 tests that verify the obvious. A professional test suite has 30 tests that cover happy path, edge cases, boundary conditions, error handling, and document the behavior with clear names.

This project demonstrates your command of the complete workflow: give code to Claude Code → generate tests → evaluate quality → refine → produce a professional suite.


Project Objective

Generate a complete suite of professional-quality unit tests for a Python utilities module, using Claude Code as the generator and your judgment as the quality evaluator.

By completing this project:

  • ✅ You'll have generated tests using the 5 prompts learned in this module
  • ✅ You'll have evaluated and refined AI-generated tests using the 5-point checklist
  • ✅ You'll have a suite of 30+ tests with professional behavior coverage
  • ✅ The tests will use AAA, parametrize, fixtures, and descriptive naming

Technical Specifications

Tech Stack

  • Language: Python 3.10+
  • Testing: pytest
  • AI: Claude Code as the test generator
  • Dependencies: Only pytest

Initial Setup

mkdir unit-test-project
cd unit-test-project

python -m venv venv
source venv/bin/activate

pip install pytest

Project Structure

unit-test-project/
├── data_utils.py           ← The code to test (given)
├── tests/
│   ├── __init__.py
│   ├── conftest.py          ← Shared fixtures
│   ├── test_validators.py   ← Validation tests
│   ├── test_formatters.py   ← Formatting tests
│   └── test_transformers.py ← Transformation tests
├── requirements.txt
└── venv/

The Code to Test: data_utils.py

Copy this file into your project. This is the module you're going to test with Claude Code.

# data_utils.py
"""Data utilities for validation, formatting, and transformation."""

import re
from datetime import datetime
from typing import Any, Optional


# === VALIDATORS ===

def validate_email(email: str) -> bool:
    """Validate email format. Returns True if valid, False otherwise."""
    if not isinstance(email, str):
        raise TypeError("Email must be a string")
    if not email or not email.strip():
        return False
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))


def validate_age(age: Any) -> tuple[bool, str]:
    """
    Validate age value. Returns (is_valid, message).
    Valid age: integer between 0 and 150 inclusive.
    """
    if not isinstance(age, int) or isinstance(age, bool):
        return (False, "Age must be an integer")
    if age < 0:
        return (False, "Age cannot be negative")
    if age > 150:
        return (False, "Age cannot exceed 150")
    return (True, "Valid")


def validate_password(password: str) -> dict:
    """
    Validate password strength.
    Returns dict with 'is_valid', 'strength', and 'issues'.
    
    Rules:
    - Minimum 8 characters
    - At least one uppercase letter
    - At least one lowercase letter
    - At least one digit
    - At least one special character (!@#$%^&*()_+-=)
    """
    if not isinstance(password, str):
        raise TypeError("Password must be a string")
    
    issues = []
    
    if len(password) < 8:
        issues.append("Must be at least 8 characters")
    if not re.search(r'[A-Z]', password):
        issues.append("Must contain uppercase letter")
    if not re.search(r'[a-z]', password):
        issues.append("Must contain lowercase letter")
    if not re.search(r'\d', password):
        issues.append("Must contain digit")
    if not re.search(r'[!@#$%^&*()_+\-=]', password):
        issues.append("Must contain special character")
    
    criteria_met = 5 - len(issues)
    
    if criteria_met == 5:
        strength = "strong"
    elif criteria_met >= 3:
        strength = "medium"
    else:
        strength = "weak"
    
    return {
        "is_valid": len(issues) == 0,
        "strength": strength,
        "issues": issues,
        "criteria_met": criteria_met,
    }


# === FORMATTERS ===

def format_currency(amount: float, currency: str = "USD") -> str:
    """
    Format number as currency string.
    Supports USD, EUR, GBP, MXN.
    """
    if not isinstance(amount, (int, float)) or isinstance(amount, bool):
        raise TypeError("Amount must be a number")
    
    symbols = {"USD": "$", "EUR": "€", "GBP": "£", "MXN": "$"}
    
    if currency not in symbols:
        raise ValueError(f"Unsupported currency: {currency}")
    
    symbol = symbols[currency]
    
    if amount < 0:
        return f"-{symbol}{abs(amount):,.2f} {currency}"
    return f"{symbol}{amount:,.2f} {currency}"


def format_name(first: str, last: str, style: str = "full") -> str:
    """
    Format name in different styles.
    Styles: 'full', 'formal', 'initial', 'last_first'
    """
    if not isinstance(first, str) or not isinstance(last, str):
        raise TypeError("Names must be strings")
    
    first = first.strip()
    last = last.strip()
    
    if not first or not last:
        raise ValueError("Names cannot be empty")
    
    styles = {
        "full": f"{first.capitalize()} {last.capitalize()}",
        "formal": f"{last.capitalize()}, {first.capitalize()}",
        "initial": f"{first[0].upper()}. {last.capitalize()}",
        "last_first": f"{last.upper()}, {first.capitalize()}",
    }
    
    if style not in styles:
        raise ValueError(f"Unknown style: {style}. Use: {', '.join(styles.keys())}")
    
    return styles[style]


def format_phone(number: str, country: str = "US") -> str:
    """
    Format phone number.
    US format: (XXX) XXX-XXXX
    MX format: +52 XX XXXX XXXX
    """
    if not isinstance(number, str):
        raise TypeError("Phone number must be a string")
    
    digits = re.sub(r'\D', '', number)
    
    if country == "US":
        if len(digits) == 11 and digits[0] == '1':
            digits = digits[1:]
        if len(digits) != 10:
            raise ValueError("US phone number must have 10 digits")
        return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
    
    elif country == "MX":
        if len(digits) == 12 and digits[:2] == '52':
            digits = digits[2:]
        if len(digits) != 10:
            raise ValueError("MX phone number must have 10 digits")
        return f"+52 {digits[:2]} {digits[2:6]} {digits[6:]}"
    
    else:
        raise ValueError(f"Unsupported country: {country}")


# === TRANSFORMERS ===

def slugify(text: str) -> str:
    """Convert text to URL-friendly slug."""
    if not isinstance(text, str):
        raise TypeError("Text must be a string")
    
    text = text.lower().strip()
    text = re.sub(r'[áàäâ]', 'a', text)
    text = re.sub(r'[éèëê]', 'e', text)
    text = re.sub(r'[íìïî]', 'i', text)
    text = re.sub(r'[óòöô]', 'o', text)
    text = re.sub(r'[úùüû]', 'u', text)
    text = re.sub(r'[ñ]', 'n', text)
    text = re.sub(r'[^a-z0-9\s-]', '', text)
    text = re.sub(r'[\s-]+', '-', text)
    text = text.strip('-')
    
    return text


def chunk_list(items: list, chunk_size: int) -> list[list]:
    """Split a list into chunks of specified size."""
    if not isinstance(items, list):
        raise TypeError("Items must be a list")
    if not isinstance(chunk_size, int) or isinstance(chunk_size, bool):
        raise TypeError("Chunk size must be an integer")
    if chunk_size < 1:
        raise ValueError("Chunk size must be at least 1")
    
    return [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]


def flatten_dict(data: dict, parent_key: str = "", separator: str = ".") -> dict:
    """
    Flatten nested dictionary.
    {"a": {"b": 1}} -> {"a.b": 1}
    """
    if not isinstance(data, dict):
        raise TypeError("Data must be a dictionary")
    
    items = {}
    for key, value in data.items():
        new_key = f"{parent_key}{separator}{key}" if parent_key else key
        if isinstance(value, dict):
            items.update(flatten_dict(value, new_key, separator))
        else:
            items[new_key] = value
    
    return items

Features to Test

1. Validators (test_validators.py)

Functions: validate_email, validate_age, validate_password

What to test:

  • Happy path: common valid inputs
  • Edge cases: empty, None, incorrect types, boundary values
  • Error handling: TypeError for invalid types
  • Business rules: each validation rule is verified individually

2. Formatters (test_formatters.py)

Functions: format_currency, format_name, format_phone

What to test:

  • Each supported style/format
  • Supported vs unsupported currencies
  • Names with spaces, capitalization
  • Phones with different input formats

3. Transformers (test_transformers.py)

Functions: slugify, chunk_list, flatten_dict

What to test:

  • Special characters, accents, ñ
  • Empty lists, chunk_size > list length
  • Dicts nested at multiple levels

Step-by-Step Process

Phase 1: Generate tests with the Complete Prompt

For each test file, use Prompt 1 (Complete) with Claude Code:

Generate unit tests for [function] in data_utils.py that cover:
1. Happy path
2. Edge cases (empty, None, incorrect types)
3. Boundary conditions
4. Error handling
5. Naming: test_[function]_[condition]_[result]
6. Pattern: arrange-act-assert
7. Use @pytest.mark.parametrize when there are multiple similar inputs

Run pytest and verify they pass:

pytest tests/ -v

Phase 2: Evaluate with the 5-Point Checklist

For each generated test, evaluate:

□ Does it test behavior, not implementation?
□ Does it cover edge cases?
□ Are the asserts meaningful?
□ Does it test error paths?
□ Would an incorrect implementation pass these tests?

Identify gaps and document them.

Phase 3: Expand with specific prompts

For each gap found, use Prompt 3 (Expansion):

The existing tests for [function] cover: [list].
Gaps found:
- [Gap 1]
- [Gap 2]
Generate additional tests ONLY for these gaps.

Phase 4: Adversarial prompt

For each function, use Prompt 4 (Adversarial):

Act as an adversarial tester for [function].
Try to find inputs that cause failures or incorrect results.

Phase 5: Organize and refine

  • Group tests into classes by behavior
  • Use parametrize where there are repetitive tests
  • Create fixtures in conftest.py for shared data
  • Verify descriptive naming across the whole suite

Success Criteria

Your project is complete when:

  • ✅ 30+ tests total distributed across the 3 test files
  • ✅ Each function in data_utils.py has happy path, edge case, and error handling tests
  • ✅ You use @pytest.mark.parametrize in at least 3 tests
  • ✅ You use fixtures in conftest.py for at least 2 shared data sets
  • ✅ All the tests follow the AAA pattern
  • ✅ Descriptive naming: you can read pytest -v and understand the system's behavior
  • ✅ You evaluated the generated tests with the 5-point checklist
  • ✅ pytest tests/ -v → all green

Evaluation Rubric (100 points)

Behavior coverage (40 points)

  • (15 pts) Validators: happy path, edge cases, error handling for the 3 functions
  • (15 pts) Formatters: all styles, currencies, phone formats
  • (10 pts) Transformers: accents, empty lists, nested dicts

Test quality (30 points)

  • (10 pts) Tests are focused (one assert per test, one behavior per test)
  • (5 pts) Descriptive naming (pytest -v is readable documentation)
  • (5 pts) Parametrize used appropriately (3+ parametrized tests)
  • (5 pts) Fixtures in conftest.py (2+ shared fixtures)
  • (5 pts) Consistent AAA pattern

Critical evaluation (20 points)

  • (10 pts) You documented the evaluation with the 5-point checklist
  • (5 pts) You identified at least 3 gaps in the initial generation
  • (5 pts) You used the adversarial prompt and found at least 1 new edge case

Organization (10 points)

  • (5 pts) Tests organized into separate files by category
  • (5 pts) Correct directory structure (tests/, conftest.py)

Extra Credit (up to +10 points)

  • (+5 pts) Additional tests for non-obvious parameter combinations
  • (+5 pts) A test that demonstrates a real bug found with the adversarial prompt

Example: conftest.py

# tests/conftest.py
import pytest


@pytest.fixture
def valid_emails():
    """Collection of valid email addresses for testing."""
    return [
        "user@example.com",
        "first.last@domain.org",
        "user+tag@example.co.uk",
        "test123@test.com",
    ]


@pytest.fixture
def invalid_emails():
    """Collection of invalid email addresses for testing."""
    return [
        "",
        "not-an-email",
        "@domain.com",
        "user@",
        "user@domain",
        "user@@domain.com",
        "   ",
    ]


@pytest.fixture
def strong_password():
    """A password that meets all criteria."""
    return "MyStr0ng!Pass"


@pytest.fixture
def sample_nested_dict():
    """Nested dictionary for flatten_dict testing."""
    return {
        "user": {
            "name": {"first": "John", "last": "Smith"},
            "age": 30,
        },
        "active": True,
    }

Example: Fragment of test_validators.py

# tests/test_validators.py
import pytest
from data_utils import validate_email, validate_age, validate_password


class TestValidateEmail:
    def test_standard_email_is_valid(self):
        assert validate_email("user@example.com") == True
    
    @pytest.mark.parametrize("email", [
        "",
        "   ",
        "not-an-email",
        "@domain.com",
        "user@",
        "user@domain",
    ])
    def test_invalid_email_formats(self, email):
        assert validate_email(email) == False
    
    def test_non_string_raises_type_error(self):
        with pytest.raises(TypeError, match="Email must be a string"):
            validate_email(123)


class TestValidateAge:
    @pytest.mark.parametrize("age,expected_valid", [
        (0, True),
        (25, True),
        (150, True),
        (-1, False),
        (151, False),
    ])
    def test_age_boundary_values(self, age, expected_valid):
        is_valid, _ = validate_age(age)
        assert is_valid == expected_valid
    
    def test_boolean_is_rejected(self):
        is_valid, message = validate_age(True)
        assert is_valid == False
        assert message == "Age must be an integer"

This is a fragment. Your complete suite should be more extensive.


Common Mistakes

Mistake 1: Trusting the first generation

Cause: Giving the prompt and accepting the tests without evaluating.

Solution: ALWAYS evaluate with the 5-point checklist. The first generation is the starting point, not the final result.

Mistake 2: Tests that test the regex implementation

Cause: Claude Code may generate tests like assert re.match(pattern, email) instead of assert validate_email(email) == True.

Solution: The tests must call the public function, not replicate the internal implementation.

Mistake 3: Not using parametrize where appropriate

Cause: Writing 10 separate tests for valid emails instead of using parametrize.

Solution: If the test logic is the same and only the input changes, use parametrize.

Mistake 4: conftest.py with fixtures used by only one file

Cause: Creating fixtures for everything in conftest.py.

Solution: Fixtures in conftest.py only if 2+ test files use them. Local fixtures go in the test file.

Mistake 5: Not testing the error message

Cause: pytest.raises(ValueError) without match.

Solution: Always include match to verify that the error message is correct:

# ❌ Only verifies that it raises ValueError
with pytest.raises(ValueError):
    format_phone("123")

# ✅ Verifies the type AND the message
with pytest.raises(ValueError, match="must have 10 digits"):
    format_phone("123")

Resources for the Project

  1. pytest Documentation - Complete reference
  2. pytest Fixtures - Fixtures guide
  3. pytest Parametrize - Parametrize reference
  4. Python re module - To understand the regex in data_utils.py
  5. Anthropic Claude Code - Official documentation

Connection with the Next Module

What you built today expands in Module 3:

  • In Module 3 you'll expand to integration and E2E tests — the other levels of the test pyramid
  • The unit tests you generated here are the base of the pyramid: fast, focused, reliable
  • The pytest patterns (AAA, parametrize, fixtures) are used at all levels of testing
  • The ability to evaluate AI-generated tests applies equally to integration and E2E tests

Your prompt-engineering skill for tests is transferable to any level of testing and any project.


Module 2, Capsule 06 — Testing with Claude Code Guide From code to a professional test suite with Claude Code