Testing Fundamentals

Updated

September 8, 2026

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 -q

Output (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) == label

Run:

uv run --with pytest pytest test_desk.py -q

Output (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 -q

Until 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 -q

Output (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 in test_desk.py. Names start with test_.
  • assert the result. pytest.raises for the error you expect.
  • Parametrize tables of inputs. Do not clone the test function.
  • uv run --with pytest pytest until the project files exist; then uv run pytest.
  • A test with no assert is not a test.

Try this

  1. Add test_format_pence_rejects_negative that expects ValueError from format_pence(-1).
  2. Add a parametrize row (100, "1.00") and run again.
  3. Make open_ticket reject an empty ticket_id. Add a test that would have failed before the check.
  4. Run uv run --with pytest pytest test_desk.py -q -k format and confirm only the format tests run.