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
pytestand plainassert - 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
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
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])$ pip install pytest
$ pytest
test_shopping.py ... [100%]
3 passed in 0.02sTip
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:
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$ pytest -q
.... [100%]
4 passed in 0.03sMocking 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.
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 usedNote
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).
$ pytest --cov=shopping
Name Stmts Miss Cover
---------------------------------
shopping.py 4 0 100%
---------------------------------
4 passed in 0.05sKey idea
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.