Testing Fundamentals
Testing Fundamentals
A test is a small program that calls your code and asserts what should be true. The boring default in this book is pytest: files named test_*.py, plain assert, and uv run pytest. You do not need a TestCase class. You do not need a mock until a real dependency is in the way.
Mental model
Save the code under test as a module (desk.py). Save tests next to it as test_desk.py. pytest loads every test_* function, runs it, and reports failures with the assertion and the values.
assert expression is enough. When it fails, pytest prints both sides. pytest.mark.parametrize runs the same test with different inputs so you do not copy the function three times.
A pyproject.toml that lists pytest as a dev dependency is optional. uv run --with pytest pytest is enough to learn. Add the project file when the desk is a real package.
Worked examples
Case 1: Two files, one assertion
Save both files in the same directory.
# desk.py
def format_pence(pence):
if pence < 0:
raise ValueError("pence must be >= 0")
return f"{pence / 100:.2f}"
def open_ticket(ticket_id, table):
if table <= 0:
raise ValueError(f"table {table}: must be positive")
return {"id": ticket_id, "table": table, "status": "open"}# test_desk.py
import pytest
from desk import format_pence, open_ticket
def test_format_pence_pounds():
assert format_pence(1200) == "12.00"
def test_open_ticket_shape():
ticket = open_ticket("T-11", 4)
assert ticket["status"] == "open"
assert ticket["table"] == 4
def test_open_ticket_rejects_table_zero():
with pytest.raises(ValueError, match="table 0"):
open_ticket("T-11", 0)Run:
uv run --with pytest pytest test_desk.py -qOutput (duration varies):
... [100%]
3 passed in 0.01s
pytest.raises is a context manager. The test fails if the call does not raise, or if the message does not match.
Case 2: Parametrize instead of copy-paste
Add this to test_desk.py (keep the functions from Case 1).
# test_desk.py
import pytest
from desk import format_pence, open_ticket
def test_open_ticket_shape():
ticket = open_ticket("T-11", 4)
assert ticket["status"] == "open"
assert ticket["table"] == 4
def test_open_ticket_rejects_table_zero():
with pytest.raises(ValueError, match="table 0"):
open_ticket("T-11", 0)
@pytest.mark.parametrize(
("pence", "label"),
[
(0, "0.00"),
(50, "0.50"),
(1200, "12.00"),
(99, "0.99"),
],
)
def test_format_pence(pence, label):
assert format_pence(pence) == labelRun:
uv run --with pytest pytest test_desk.py -qOutput (duration varies):
...... [100%]
6 passed in 0.01s
Four rows in the table plus two other tests is six. A failing row names the arguments: pence=99. That is why parametrize beats three near-identical functions.
Case 3: Optional pyproject.toml
When the desk is a project, pin pytest once.
# pyproject.toml
[project]
name = "desk"
version = "0.1.0"
requires-python = ">=3.14"
[dependency-groups]
dev = ["pytest>=8"]Then, from that directory:
uv sync --group dev
uv run pytest test_desk.py -qUntil you have a project, keep using uv run --with pytest pytest test_desk.py. Same tests.
The trap
Save as test_silent.py next to desk.py. A test without an assert always passes. pytest does not read your mind.
# test_silent.py
from desk import format_pence
def test_format_pence():
format_pence(1200)Run:
uv run --with pytest pytest test_silent.py -qOutput (duration varies):
. [100%]
1 passed in 0.01s
The function returned "12.00" and nobody looked. Add assert format_pence(1200) == "12.00". A green bar that never asserted is a live bug.
The boring rule
- Code in
desk.py. Tests intest_desk.py. Names start withtest_. assertthe result.pytest.raisesfor the error you expect.- Parametrize tables of inputs. Do not clone the test function.
uv run --with pytest pytestuntil the project files exist; thenuv run pytest.- A test with no assert is not a test.
Try this
- Add
test_format_pence_rejects_negativethat expectsValueErrorfromformat_pence(-1). - Add a parametrize row
(100, "1.00")and run again. - Make
open_ticketreject an emptyticket_id. Add a test that would have failed before the check. - Run
uv run --with pytest pytest test_desk.py -q -k formatand confirm only the format tests run.