TDD with Python and pytest: A Complete Hands-On Tutorial

TDD with Python and pytest: A Complete Hands-On Tutorial

Python and pytest are a natural fit for TDD. pytest has minimal ceremony, excellent error messages, and a parametrize decorator that makes covering edge cases straightforward. This tutorial builds an order pricing engine from scratch using strict red-green-refactor discipline. No implementation code gets written until a test demands it.

Setup

You need Python 3.8+ and pip. Create a virtual environment and install pytest:

mkdir order-pricer && <span class="hljs-built_in">cd order-pricer
python -m venv .venv
<span class="hljs-built_in">source .venv/bin/activate  <span class="hljs-comment"># Windows: .venv\Scripts\activate
pip install pytest

Create two files:

touch order_pricer.py
<span class="hljs-built_in">touch test_order_pricer.py

Leave order_pricer.py empty. Open a second terminal and run:

pytest --tb=short -q

You will see "no tests ran." Now we start.

Round 1: Base Price Calculation

Our order pricer needs to calculate a subtotal from a list of items. Each item has a price and a quantity.

Red:

# test_order_pricer.py
from order_pricer import calculate_subtotal

def test_single_item_subtotal():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_subtotal(items) == 20.00

def test_multiple_items_subtotal():
    items = [
        {"price": 10.00, "quantity": 2},
        {"price": 5.50, "quantity": 4},
    ]
    assert calculate_subtotal(items) == 42.00

def test_empty_cart_subtotal():
    assert calculate_subtotal([]) == 0.00

Run pytest. Output:

ImportError: cannot import name 'calculate_subtotal' from 'order_pricer'

Red. Good.

Green:

# order_pricer.py
def calculate_subtotal(items: list) -> float:
    return sum(item["price"] * item["quantity"] for item in items)

Run pytest. All 3 tests pass. Green.

Refactor: The implementation is clean. Add a type alias for clarity and keep moving.

from typing import TypedDict

class OrderItem(TypedDict):
    price: float
    quantity: int

def calculate_subtotal(items: list[OrderItem]) -> float:
    return sum(item["price"] * item["quantity"] for item in items)

Still green. The TypedDict gives future developers better IDE support and makes the expected structure explicit.

Round 2: Discount Codes

Red:

from order_pricer import calculate_subtotal, apply_discount

VALID_CODES = {
    "SAVE10": 0.10,
    "SAVE25": 0.25,
    "HALFOFF": 0.50,
}

def test_valid_discount_code_reduces_price():
    assert apply_discount(100.00, "SAVE10") == 90.00

def test_25_percent_discount():
    assert apply_discount(100.00, "SAVE25") == 75.00

def test_invalid_discount_code_raises_error():
    with pytest.raises(ValueError, match="Invalid discount code: FAKE50"):
        apply_discount(100.00, "FAKE50")

def test_no_discount_returns_original_price():
    assert apply_discount(100.00, None) == 100.00

Remember to add import pytest at the top of the test file.

Run pytest. Fails on import — apply_discount does not exist.

Green:

DISCOUNT_CODES = {
    "SAVE10": 0.10,
    "SAVE25": 0.25,
    "HALFOFF": 0.50,
}

def apply_discount(price: float, code: str | None) -> float:
    if code is None:
        return price
    if code not in DISCOUNT_CODES:
        raise ValueError(f"Invalid discount code: {code}")
    discount_rate = DISCOUNT_CODES[code]
    return price * (1 - discount_rate)

All 7 tests pass.

Refactor: The discount lookup works but it is buried in the function. Make the registry explicit:

from decimal import Decimal

DISCOUNT_REGISTRY: dict[str, Decimal] = {
    "SAVE10": Decimal("0.10"),
    "SAVE25": Decimal("0.25"),
    "HALFOFF": Decimal("0.50"),
}

def apply_discount(price: float, code: str | None) -> float:
    if code is None:
        return price
    if code not in DISCOUNT_REGISTRY:
        raise ValueError(f"Invalid discount code: {code}")
    rate = DISCOUNT_REGISTRY[code]
    return float(Decimal(str(price)) * (1 - rate))

We switched to Decimal arithmetic to avoid floating point issues. Run pytest. Still 7 tests, all green.

Round 3: Tax Calculation

Red:

from order_pricer import calculate_subtotal, apply_discount, calculate_tax

def test_standard_tax_rate():
    # 8.5% tax on $100 = $8.50
    assert calculate_tax(100.00, rate=0.085) == pytest.approx(8.50)

def test_zero_tax_rate():
    assert calculate_tax(100.00, rate=0.0) == 0.00

def test_tax_on_discounted_price():
    discounted = apply_discount(100.00, "SAVE25")  # $75
    tax = calculate_tax(discounted, rate=0.10)      # $7.50
    assert tax == pytest.approx(7.50)

Note: pytest.approx handles floating point comparisons gracefully.

Green:

def calculate_tax(price: float, rate: float) -> float:
    return float(Decimal(str(price)) * Decimal(str(rate)))

All 10 tests pass.

Round 4: Full Order Totals with parametrize

For the final step, we compose everything into a calculate_order_total function. Use pytest.mark.parametrize to cover multiple scenarios efficiently.

Red:

import pytest
from order_pricer import calculate_order_total

@pytest.mark.parametrize("items,discount_code,tax_rate,expected_total", [
    (
        [{"price": 50.00, "quantity": 2}],
        None,
        0.10,
        110.00,  # (50 * 2) + 10% tax
    ),
    (
        [{"price": 100.00, "quantity": 1}],
        "SAVE10",
        0.085,
        97.35,  # 90 + 8.5% tax on 90 = 97.65... recalculate: 90 * 1.085 = 97.65
    ),
    (
        [],
        None,
        0.10,
        0.00,  # empty cart
    ),
])
def test_calculate_order_total(items, discount_code, tax_rate, expected_total):
    total = calculate_order_total(items, discount_code=discount_code, tax_rate=tax_rate)
    assert total == pytest.approx(expected_total, rel=1e-2)

Adjust the expected values to match your decimal arithmetic after running. The point is the structure.

Green:

def calculate_order_total(
    items: list[OrderItem],
    discount_code: str | None = None,
    tax_rate: float = 0.0,
) -> float:
    subtotal = calculate_subtotal(items)
    discounted = apply_discount(subtotal, discount_code)
    tax = calculate_tax(discounted, tax_rate)
    return float(Decimal(str(discounted)) + Decimal(str(tax)))

Refactor: The composition function is already clean. Extract constants for default values and add a docstring:

DEFAULT_TAX_RATE = 0.0

def calculate_order_total(
    items: list[OrderItem],
    discount_code: str | None = None,
    tax_rate: float = DEFAULT_TAX_RATE,
) -> float:
    """
    Calculate the final order total including discounts and tax.

    Args:
        items: List of order items with price and quantity.
        discount_code: Optional discount code to apply to subtotal.
        tax_rate: Tax rate as a decimal (0.085 = 8.5%).

    Returns:
        Final order total as a float.
    """
    subtotal = calculate_subtotal(items)
    discounted = apply_discount(subtotal, discount_code)
    tax = calculate_tax(discounted, tax_rate)
    return float(Decimal(str(discounted)) + Decimal(str(tax)))

Still all green.

Using Fixtures for Shared Setup

As your test suite grows, use pytest fixtures to avoid repeated setup:

import pytest
from order_pricer import calculate_order_total

@pytest.fixture
def standard_cart():
    return [
        {"price": 29.99, "quantity": 1},
        {"price": 9.99, "quantity": 3},
    ]

def test_cart_total_no_discount(standard_cart):
    total = calculate_order_total(standard_cart, tax_rate=0.10)
    # 29.99 + 29.97 = 59.96, +10% = 65.956
    assert total == pytest.approx(65.96, rel=1e-2)

def test_cart_total_with_discount(standard_cart):
    total = calculate_order_total(standard_cart, discount_code="SAVE25", tax_rate=0.10)
    # 59.96 * 0.75 = 44.97, +10% = 49.467
    assert total == pytest.approx(49.47, rel=1e-2)

Fixtures eliminate duplicated setup code and make test intent clearer. The fixture name describes what state you are starting from.

Running the Full Suite

pytest -v

Sample output:

test_order_pricer.py::test_single_item_subtotal PASSED
test_order_pricer.py::test_multiple_items_subtotal PASSED
test_order_pricer.py::test_empty_cart_subtotal PASSED
test_order_pricer.py::test_valid_discount_code_reduces_price PASSED
test_order_pricer.py::test_25_percent_discount PASSED
test_order_pricer.py::test_invalid_discount_code_raises_error PASSED
test_order_pricer.py::test_no_discount_returns_original_price PASSED
test_order_pricer.py::test_standard_tax_rate PASSED
test_order_pricer.py::test_zero_tax_rate PASSED
test_order_pricer.py::test_tax_on_discounted_price PASSED
test_order_pricer.py::test_calculate_order_total[...] PASSED (3)
test_order_pricer.py::test_cart_total_no_discount PASSED
test_order_pricer.py::test_cart_total_with_discount PASSED

13 passed in 0.12s

What pytest Gives You

pytest's output on failure is unusually good. When an assertion fails, it shows you the actual values on both sides of the comparison without any extra logging. This matters in TDD because you spend time in the red phase — clear failure messages get you to green faster.

The combination of fixtures, parametrize, and clean assertion messages makes pytest one of the most productive TDD environments available. The 13 tests above run in 0.12 seconds, which means you can run them on every save without breaking your flow.

The order pricer module is now fully specified by its tests. Any developer who picks it up knows exactly what it does, what errors it raises, and what edge cases are handled — without reading the implementation code. That is the documentation value of TDD that no comment or README can match.

Read more