Phase 5 · Advanced PythonModule 32~38 min read

Testing

Prove your code works with unittest, pytest, fixtures, and mocking.

What you'll learn

Tests are code that checks your other code. They let you change things fearlessly, catch regressions instantly, and document how your functions are meant to behave. This lesson uses pytest, the de-facto standard.

By the end of this lesson you'll be able to:

  • Write and run tests with pytest and plain assert
  • Share setup with fixtures and cover many cases with parameterization
  • Test error paths with pytest.raises
  • Isolate code from slow dependencies with mocks
  • Measure coverage and follow the test-driven cycle

Why automated tests

Manual testing doesn't scale: you can't re-check every feature by hand after each change. Automated tests run in seconds, every time, and tell you exactly what broke. Here's the function we'll test:

shopping.py
# shopping.py
def total(prices, tax=0.0):
    if any(p < 0 for p in prices):
        raise ValueError("prices must be non-negative")
    return round(sum(prices) * (1 + tax), 2)

Your first pytest tests

A pytest test is just a function named test_* that uses assert. Put them in a test_*.py file, run pytest, and it discovers and runs them automatically — showing rich output when an assertion fails.

test_shopping.py
# test_shopping.py
import pytest
from shopping import total

def test_sums_prices():
    assert total([10, 5, 2]) == 17.0

def test_applies_tax():
    assert total([100], tax=0.1) == 110.0

def test_rejects_negative():
    with pytest.raises(ValueError):
        total([10, -1])
terminal
$ pip install pytest
$ pytest
test_shopping.py ...                     [100%]
3 passed in 0.02s

Tip

Test the error paths, not just the happy path. with pytest.raises(ValueError): asserts that the code inside does raise — a bug that stops raising will fail the test.

Fixtures & parameterization

A fixture provides reusable setup (data, a temp file, a database connection); request it by naming it as a parameter. @pytest.mark.parametrize runs the same test across many inputs — one test function, many cases:

test_more.py
import pytest
from shopping import total

@pytest.fixture
def cart():
    return [10, 20, 30]          # fresh data for each test that asks for it

def test_with_fixture(cart):
    assert total(cart) == 60.0

@pytest.mark.parametrize("prices, expected", [
    ([10], 10.0),
    ([1, 2, 3], 6.0),
    ([], 0.0),
])
def test_totals(prices, expected):
    assert total(prices) == expected
terminal
$ pytest -q
....                                     [100%]
4 passed in 0.03s

Mocking dependencies

A mock is a stand-in for a real dependency — a database, an API, the clock — so your test stays fast, deterministic, and offline. You control what it returns and can assert how it was called.

test_mock.py
from unittest.mock import Mock

def greet(db, uid):
    user = db.get_user(uid)          # a slow/real dependency
    return f"Hi {user['name']}"

# Replace the real db with a fake that returns what we want
db = Mock()
db.get_user.return_value = {"name": "Ada"}

print(greet(db, 1))                  # Hi Ada
db.get_user.assert_called_once_with(1)   # verify how it was used

Note

Mock at the boundaries — network, disk, time, randomness — not your own logic. Over-mocking produces tests that pass while the real code is broken. The goal is fast, reliable tests, not mocking for its own sake.

Coverage & TDD

Coverage reports which lines your tests actually exercise — a useful map of untested code (though 100% coverage isn't the same as correctness).

terminal
$ pytest --cov=shopping
Name          Stmts   Miss  Cover
---------------------------------
shopping.py       4      0   100%
---------------------------------
4 passed in 0.05s

Key idea

Test-Driven Development (TDD) flips the order: write a failing test (red), write the minimum code to pass it (green), then clean up (refactor). It keeps code testable by design and gives you a safety net before you build.

Recap & quick check

Key takeaways

  • A pytest test is a test_* function using plain assert; pytest discovers and runs test_*.py files.
  • Use with pytest.raises(SomeError): to assert that code raises — test error paths, not just success.
  • Fixtures provide reusable setup, requested by naming them as parameters.
  • @pytest.mark.parametrize runs one test across many input/expected pairs.
  • Mocks replace real dependencies (network, DB, time) so tests stay fast and deterministic; mock at the boundaries.
  • Coverage shows which lines ran; TDD is the red-green-refactor cycle (failing test first).

Quick check

1. How does pytest recognize a test?

2. How do you assert that a call raises ValueError?

3. What does @pytest.mark.parametrize do?

4. What should you mock?

5. What is the TDD cycle?

Tests tell you that something is wrong; the next skills tell you where and why. Next up: Module 33 — Debugging, Logging & Profiling.