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: ValueError with the message "Cannot divide by zero"
  • Invalid types: TypeError with 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 (int or float, not bool, str, None, etc.)
  • Division by zero raises ValueError with the message "Cannot divide by zero"
  • Invalid types raise TypeError with 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

  1. Write ALL the tests for Phase A (basic operations) → pytest → they all fail (RED)
  2. Give the Phase A tests to Claude Code → Claude implements → pytest → it should pass (GREEN)
  3. Write the Phase B tests (errors) → pytest → some fail (RED)
  4. Give the Phase B tests to Claude Code → Claude extends → pytest → it should pass (GREEN)
  5. Write the Phase C tests (history) → pytest → they fail (RED)
  6. Give the Phase C tests to Claude Code → Claude extends → pytest → it should pass (GREEN)
  7. 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

  1. pytest Documentation - Complete pytest reference
  2. pytest.raises - How to test exceptions
  3. pytest.approx - Float comparisons with tolerance
  4. Python: isinstance - isinstance documentation and gotchas with bool
  5. 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.py to 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