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.pyhas happy path, edge case, and error handling tests - ✅ You use
@pytest.mark.parametrizein at least 3 tests - ✅ You use fixtures in
conftest.pyfor at least 2 shared data sets - ✅ All the tests follow the AAA pattern
- ✅ Descriptive naming: you can read
pytest -vand 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
- pytest Documentation - Complete reference
- pytest Fixtures - Fixtures guide
- pytest Parametrize - Parametrize reference
- Python re module - To understand the regex in data_utils.py
- 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