Module 1: Spec-First Methodology and TDD with AI
Module Project: Spec-First Mini-App (Calculator)
Module Project: Spec-First Mini-App (Calculator)
Project overview
You've learned the spec-first philosophy, understood why TDD matters more with AI, mastered the anatomy of a good test-spec, and run your first spec→implement cycle. Now it's time to integrate it all into a complete project.
You're going to build a calculator app using spec-first methodology with Claude Code. It's not a trivial calculator — it includes basic operations, error handling, edge cases like division by zero, and an operation history feature. The focus isn't the complexity of the calculator — it's experiencing the spec-first workflow from start to finish with a familiar domain.
The project follows a clear flow: you define tests for each feature → you give the tests to Claude Code → Claude Code implements → pytest validates → you iterate if needed → you move on to the next feature. By the end, you'll have a complete calculator with 25+ tests as a safety net.
This project demonstrates the fundamental pattern you'll use throughout the guide: tests as specification, Claude Code as implementer, pytest as validator.
Project Objective
Build a calculator with spec-first methodology where YOU define the behavior with tests and Claude Code implements the logic.
By completing this project:
- ✅ You'll have run multiple spec→implement→validate cycles with Claude Code
- ✅ You'll have a calculator with 25+ tests as executable documentation of the behavior
- ✅ You'll have experienced iteration loops when tests fail
- ✅ You'll have a reference project for the spec-first pattern
Technical Specifications
Tech Stack
- Language: Python 3.10+
- Testing: pytest
- AI: Claude Code as implementer
- Dependencies: Only pytest (no additional dependencies)
Initial Setup
# Create the project
mkdir calculator-spec-first
cd calculator-spec-first
# Virtual environment
python -m venv venv
source venv/bin/activate # Mac/Linux
# venv\Scripts\activate # Windows
# Install dependencies
pip install pytest
# Create the structure
touch calculator.py test_calculator.py
Project Structure
calculator-spec-first/
├── calculator.py ← Claude Code implements here
├── test_calculator.py ← You write the tests here
├── requirements.txt ← Only pytest
└── venv/
requirements.txt:
pytest>=8.0.0
Required Features
1. Basic Operations
Your calculator must have these functions with this behavior:
add(a, b) — Adds two numbers
add(2, 3) # → 5
add(-1, 1) # → 0
add(0.1, 0.2) # → 0.3 (approx)
subtract(a, b) — Subtracts b from a
subtract(5, 3) # → 2
subtract(3, 5) # → -2
subtract(0, 0) # → 0
multiply(a, b) — Multiplies two numbers
multiply(3, 4) # → 12
multiply(-2, 3) # → -6
multiply(0, 100) # → 0
divide(a, b) — Divides a by b
divide(10, 2) # → 5.0
divide(7, 2) # → 3.5
divide(10, 0) # → ValueError
2. Error Handling
The calculator must handle errors clearly:
- Division by zero:
ValueErrorwith the message "Cannot divide by zero" - Invalid types:
TypeErrorwith the message "Arguments must be numbers"
3. Operation History
The calculator must maintain a history:
calc = Calculator()
calc.add(2, 3) # → 5
calc.multiply(4, 5) # → 20
calc.get_history() # → [{"operation": "add", "args": [2, 3], "result": 5}, ...]
calc.clear_history() # → Clears the history
calc.get_history() # → []
4. Last Operation
calc = Calculator()
calc.add(2, 3)
calc.get_last_result() # → 5
calc.multiply(4, 5)
calc.get_last_result() # → 20
Step by Step: How to Build the Project
Phase A: Tests for Basic Operations
Start with the simplest tests. Create test_calculator.py:
# test_calculator.py
import pytest
from calculator import Calculator
class TestAdd:
"""Spec: add(a, b) returns the sum of two numbers"""
def test_add_positive_numbers(self):
calc = Calculator()
assert calc.add(2, 3) == 5
def test_add_negative_numbers(self):
calc = Calculator()
assert calc.add(-1, -1) == -2
def test_add_positive_and_negative(self):
calc = Calculator()
assert calc.add(-1, 1) == 0
def test_add_zeros(self):
calc = Calculator()
assert calc.add(0, 0) == 0
def test_add_floats(self):
calc = Calculator()
assert calc.add(0.1, 0.2) == pytest.approx(0.3)
def test_add_large_numbers(self):
calc = Calculator()
assert calc.add(1_000_000, 2_000_000) == 3_000_000
class TestSubtract:
"""Spec: subtract(a, b) returns a - b"""
def test_subtract_basic(self):
calc = Calculator()
assert calc.subtract(5, 3) == 2
def test_subtract_result_negative(self):
calc = Calculator()
assert calc.subtract(3, 5) == -2
def test_subtract_zeros(self):
calc = Calculator()
assert calc.subtract(0, 0) == 0
def test_subtract_same_number(self):
calc = Calculator()
assert calc.subtract(42, 42) == 0
def test_subtract_floats(self):
calc = Calculator()
assert calc.subtract(1.5, 0.5) == pytest.approx(1.0)
class TestMultiply:
"""Spec: multiply(a, b) returns a * b"""
def test_multiply_basic(self):
calc = Calculator()
assert calc.multiply(3, 4) == 12
def test_multiply_by_zero(self):
calc = Calculator()
assert calc.multiply(100, 0) == 0
def test_multiply_by_one(self):
calc = Calculator()
assert calc.multiply(42, 1) == 42
def test_multiply_negatives(self):
calc = Calculator()
assert calc.multiply(-2, -3) == 6
def test_multiply_positive_negative(self):
calc = Calculator()
assert calc.multiply(-2, 3) == -6
def test_multiply_floats(self):
calc = Calculator()
assert calc.multiply(2.5, 4) == pytest.approx(10.0)
class TestDivide:
"""Spec: divide(a, b) returns a / b as float"""
def test_divide_even(self):
calc = Calculator()
assert calc.divide(10, 2) == 5.0
def test_divide_with_remainder(self):
calc = Calculator()
assert calc.divide(7, 2) == 3.5
def test_divide_by_one(self):
calc = Calculator()
assert calc.divide(42, 1) == 42.0
def test_divide_zero_numerator(self):
calc = Calculator()
assert calc.divide(0, 5) == 0.0
def test_divide_negative(self):
calc = Calculator()
assert calc.divide(-10, 2) == -5.0
def test_divide_by_zero_raises_error(self):
calc = Calculator()
with pytest.raises(ValueError, match="Cannot divide by zero"):
calc.divide(10, 0)
def test_divide_float_precision(self):
calc = Calculator()
assert calc.divide(1, 3) == pytest.approx(0.333333, rel=1e-4)
Run pytest (they should fail because calculator.py is empty):
pytest test_calculator.py -v
# ERRORS - ImportError
Give the tests to Claude Code:
Create calculator.py with a Calculator class that has methods
add, subtract, multiply, and divide. The tests in test_calculator.py
define the exact behavior. Implement so they all pass.
Run pytest to validate:
pytest test_calculator.py -v
# It should show 24 passed
Phase B: Tests for Error Handling
Add tests for type validation:
class TestTypeValidation:
"""Spec: Calculator raises TypeError for non-numeric inputs"""
def test_add_string_raises_type_error(self):
calc = Calculator()
with pytest.raises(TypeError, match="Arguments must be numbers"):
calc.add("hello", 3)
def test_subtract_none_raises_type_error(self):
calc = Calculator()
with pytest.raises(TypeError, match="Arguments must be numbers"):
calc.subtract(None, 3)
def test_multiply_list_raises_type_error(self):
calc = Calculator()
with pytest.raises(TypeError, match="Arguments must be numbers"):
calc.multiply([1, 2], 3)
def test_divide_bool_raises_type_error(self):
calc = Calculator()
with pytest.raises(TypeError, match="Arguments must be numbers"):
calc.divide(True, 3)
Note on booleans: In Python, bool is a subclass of int (True == 1, False == 0). Your test defines that booleans are NOT accepted as numbers. This forces Claude Code to do an explicit validation that excludes bool, not just check isinstance(x, (int, float)).
Run pytest — some probably fail:
pytest test_calculator.py::TestTypeValidation -v
If Claude Code's implementation doesn't handle types, give it the pytest output and ask for a fix. This is the iteration loop in action.
Phase C: Tests for History
class TestHistory:
"""Spec: Calculator maintains operation history"""
def test_history_starts_empty(self):
calc = Calculator()
assert calc.get_history() == []
def test_add_records_in_history(self):
calc = Calculator()
calc.add(2, 3)
history = calc.get_history()
assert len(history) == 1
assert history[0]["operation"] == "add"
assert history[0]["args"] == [2, 3]
assert history[0]["result"] == 5
def test_multiple_operations_in_history(self):
calc = Calculator()
calc.add(2, 3)
calc.multiply(4, 5)
history = calc.get_history()
assert len(history) == 2
assert history[0]["operation"] == "add"
assert history[1]["operation"] == "multiply"
def test_clear_history(self):
calc = Calculator()
calc.add(1, 1)
calc.subtract(5, 3)
calc.clear_history()
assert calc.get_history() == []
def test_history_after_clear_records_new(self):
calc = Calculator()
calc.add(1, 1)
calc.clear_history()
calc.multiply(3, 3)
history = calc.get_history()
assert len(history) == 1
assert history[0]["operation"] == "multiply"
def test_failed_operation_not_in_history(self):
calc = Calculator()
with pytest.raises(ValueError):
calc.divide(10, 0)
assert calc.get_history() == []
class TestLastResult:
"""Spec: Calculator tracks last operation result"""
def test_last_result_after_add(self):
calc = Calculator()
calc.add(2, 3)
assert calc.get_last_result() == 5
def test_last_result_updates(self):
calc = Calculator()
calc.add(2, 3)
calc.multiply(4, 5)
assert calc.get_last_result() == 20
def test_last_result_none_initially(self):
calc = Calculator()
assert calc.get_last_result() is None
def test_last_result_after_clear_history(self):
calc = Calculator()
calc.add(2, 3)
calc.clear_history()
assert calc.get_last_result() is None
Give the new tests to Claude Code and ask it to extend calculator.py.
Validations and Error Handling
Required Validations
- All numeric arguments are validated (
intorfloat, notbool,str,None, etc.) - Division by zero raises
ValueErrorwith the message "Cannot divide by zero" - Invalid types raise
TypeErrorwith the message "Arguments must be numbers" - Failed operations are NOT recorded in the history
Expected Error Handling
# Your implementation must handle:
calc = Calculator()
# TypeError for invalid types
try:
calc.add("hello", 3)
except TypeError as e:
print(e) # "Arguments must be numbers"
# ValueError for division by zero
try:
calc.divide(10, 0)
except ValueError as e:
print(e) # "Cannot divide by zero"
# A failed operation doesn't affect the history
assert calc.get_history() == []
assert calc.get_last_result() is None
Success Criteria
Your project is complete when:
- ✅ All tests pass (
pytest test_calculator.py -v→ all green) - ✅ You have 25+ tests covering operations, errors, history, and last result
- ✅ You ran the spec-first workflow: tests first, implementation after
- ✅ You experienced at least one iteration loop (test fails → Claude Code fixes)
- ✅ Claude Code's implementation meets your specification exactly
Evaluation Rubric (100 points)
Functionality (50 points)
- (15 pts) The 4 operations work correctly (add, subtract, multiply, divide)
- (10 pts) Division by zero raises ValueError with the correct message
- (10 pts) Invalid types raise TypeError with the correct message
- (10 pts) History records operations correctly
- (5 pts) get_last_result works and updates correctly
Tests (30 points)
- (10 pts) 25+ tests with good coverage
- (5 pts) Tests are deterministic and independent
- (5 pts) Tests have descriptive names
- (5 pts) Edge cases covered (0, negatives, floats, invalid types)
- (5 pts) Tests use pytest correctly (assert, pytest.raises, pytest.approx)
Workflow (20 points)
- (10 pts) Tests were written BEFORE the implementation (spec-first)
- (5 pts) At least one iteration loop was run (failure → fix)
- (5 pts) Clean and organized code
Extra Credit (up to +10 points)
- (+5 pts) Additional tests for edge cases not listed (very large numbers, infinity, NaN)
- (+5 pts) Add an extra function (power, sqrt, modulo) with spec-first tests
Minimal Implementation Example
This is a functional skeleton that shows the expected structure. It is NOT the complete solution — it's the starting point that Claude Code should expand.
# calculator.py — Skeleton (NOT the complete solution)
class Calculator:
def __init__(self):
self._history = []
self._last_result = None
def _validate_args(self, a, b):
"""Validate that both arguments are numbers (not bool)."""
# TODO: Implement validation
pass
def _record(self, operation, args, result):
"""Record operation in history."""
# TODO: Implement recording
pass
def add(self, a, b):
# TODO: Implement
pass
def subtract(self, a, b):
# TODO: Implement
pass
def multiply(self, a, b):
# TODO: Implement
pass
def divide(self, a, b):
# TODO: Implement
pass
def get_history(self):
# TODO: Implement
pass
def clear_history(self):
# TODO: Implement
pass
def get_last_result(self):
# TODO: Implement
pass
This example:
- ✅ Shows the expected structure (a class with methods)
- ✅ Indicates the methods that must exist
- ❌ Does NOT include the logic (that's what Claude Code implements based on your tests)
Common Mistakes
Mistake 1: Writing the implementation before the tests
Cause: A development habit without TDD.
Solution: Open test_calculator.py first. Don't touch calculator.py until you have tests written and run (RED phase).
Mistake 2: Tests that aren't independent
Cause: Using a shared Calculator instance across tests.
Solution: Each test creates its own instance:
# ❌ Shared (dependent)
calc = Calculator() # At module level
def test_add():
calc.add(2, 3) # Modifies history for other tests
# ✅ Independent
def test_add():
calc = Calculator() # Its own instance
calc.add(2, 3)
Mistake 3: Not testing error handling
Cause: Focusing only on the happy path.
Solution: The TestTypeValidation tests and the division-by-zero test are required. Without them, the calculator silently accepts any input.
Mistake 4: pytest.approx not used with floats
Cause: Floats in Python have inherent imprecision.
Solution: Always use pytest.approx when comparing floats:
# ❌ Can fail due to imprecision
assert calc.add(0.1, 0.2) == 0.3
# ✅ Correct
assert calc.add(0.1, 0.2) == pytest.approx(0.3)
Mistake 5: Not giving Claude Code enough context
Cause: Just saying "implement calculator" without giving the tests.
Solution: Always include the tests as context:
"Implement calculator.py so that all the tests in
test_calculator.py pass. The tests define the behavior."
Mistake 6: Boolean as a number
Cause: In Python, bool is a subclass of int. isinstance(True, int) returns True.
Solution: Your test_divide_bool_raises_type_error test forces explicit validation. If Claude Code's implementation uses only isinstance(x, (int, float)), the boolean test will fail. Give the pytest output to Claude Code so it fixes it.
# Validation that excludes bool:
def _validate_args(self, a, b):
for arg in (a, b):
if isinstance(arg, bool) or not isinstance(arg, (int, float)):
raise TypeError("Arguments must be numbers")
Recommended Process
Suggested order
- Write ALL the tests for Phase A (basic operations) →
pytest→ they all fail (RED) - Give the Phase A tests to Claude Code → Claude implements →
pytest→ it should pass (GREEN) - Write the Phase B tests (errors) →
pytest→ some fail (RED) - Give the Phase B tests to Claude Code → Claude extends →
pytest→ it should pass (GREEN) - Write the Phase C tests (history) →
pytest→ they fail (RED) - Give the Phase C tests to Claude Code → Claude extends →
pytest→ it should pass (GREEN) - Final validation:
pytest test_calculator.py -v→ 25+ passed
Why this order
Going phase by phase simulates the real TDD workflow:
- You don't define everything at once — you define incrementally
- Each phase adds complexity on top of the previous one
- If something fails in Phase B, it doesn't affect what already works in Phase A
- You experience multiple RED→GREEN cycles per feature
Final Verification
When you finish, run the complete verification:
# All tests should pass
pytest test_calculator.py -v
# Verify the number of tests
pytest test_calculator.py --co -q
# It should show 25+ tests collected
Expected output:
test_calculator.py::TestAdd::test_add_positive_numbers PASSED
test_calculator.py::TestAdd::test_add_negative_numbers PASSED
test_calculator.py::TestAdd::test_add_positive_and_negative PASSED
test_calculator.py::TestAdd::test_add_zeros PASSED
test_calculator.py::TestAdd::test_add_floats PASSED
test_calculator.py::TestAdd::test_add_large_numbers PASSED
test_calculator.py::TestSubtract::test_subtract_basic PASSED
...
test_calculator.py::TestHistory::test_failed_operation_not_in_history PASSED
test_calculator.py::TestLastResult::test_last_result_after_add PASSED
test_calculator.py::TestLastResult::test_last_result_updates PASSED
test_calculator.py::TestLastResult::test_last_result_none_initially PASSED
test_calculator.py::TestLastResult::test_last_result_after_clear_history PASSED
========================= 28+ passed in 0.05s =========================
Resources for the Project
- pytest Documentation - Complete pytest reference
- pytest.raises - How to test exceptions
- pytest.approx - Float comparisons with tolerance
- Python: isinstance - isinstance documentation and gotchas with bool
- Anthropic: Claude Code - Official Claude Code documentation
Connection with the Next Module
What you built today expands in Module 2:
- In Module 2 you'll learn to generate tests with Claude Code (not just write them yourself) — prompts that produce professional-quality tests
- The pytest patterns you used here (assert, pytest.raises, pytest.approx) go deeper with parametrize, fixtures, and advanced naming conventions
- The calculator you built here can serve as a base to practice test generation: give
calculator.pyto Claude Code and ask it to generate additional tests
The spec-first workflow you mastered here is the foundation of the ENTIRE guide. Every module applies it with more complexity.
Final Reflection
Before moving on to Module 2, reflect:
What did you experience?
- Writing tests first feels different — you think about behavior before implementation
- Tests eliminate ambiguity — Claude Code knows exactly what to implement
- pytest gives precise feedback — you don't need to explain bugs in natural language
- The iteration loop is fast — failure → feedback → fix → validation in minutes
What will change in your workflow?
- Before: "Claude, implement X" → visual review → "looks good"
- Now: you write tests → "Claude, implement so these tests pass" → pytest validates → real confidence
Your superpower: You're not slower for writing tests first. You're faster — because you don't waste time debugging bugs you would have caught with tests. And you're more reliable — because pytest validates in 0.05 seconds what would take 30 minutes of manual review.
Module 1, Capsule 06 — Testing with Claude Code Guide Your first app built with spec-first methodology