Intro to pytest
pytest has become a widely used test framework for Python.
Install
pytest is a development dependency:
uv add --dev pytest
Test Discovery
pytest finds tests by convention. Drop them in a tests/ directory at the project root:
your-project/
├── slug.py
└── tests/
└── test_slug.py
The rules:
- Test files are named
test_*.py. - Test functions inside those files are named
test_*. - No
__init__.pyis needed intests/.
Anything matching those names gets picked up automatically. Anything else is ignored.
A First Test
Say we have a slugify function in slug.py:
def slugify(name: str) -> str:
return name.strip().lower().replace(" ", "-")
The test goes in tests/test_slug.py:
from slug import slugify
def test_slugify_lowercases_and_dashes_spaces():
assert slugify("Release Tracker") == "release-tracker"
def test_slugify_strips_outer_whitespace():
assert slugify(" hello ") == "hello"
Two tests, two assertions. No fixtures, no setup, no teardown. The test name is half the documentation.
Running the Suite
uv run pytest
When everything passes:
============================= test session starts ==============================
collected 2 items
tests/test_slug.py .. [100%]
============================== 2 passed in 0.02s ===============================
Each dot is a passing test. Two dots, two passes.
When something fails, pytest shows the failing assertion, the values on each side, and a diff:
=================================== FAILURES ===================================
________________ test_slugify_lowercases_and_dashes_spaces _____________________
def test_slugify_lowercases_and_dashes_spaces():
> assert slugify("Release Tracker") == "release-tracker"
E AssertionError: assert 'Release-Tracker' == 'release-tracker'
E - release-tracker
E + Release-Tracker
tests/test_slug.py:5: AssertionError
=========================== short test summary info ============================
FAILED tests/test_slug.py::test_slugify_lowercases_and_dashes_spaces
========================= 1 failed, 1 passed in 0.03s ==========================
That diff is one of pytest's killer features.
Info
It rewrites the bytecode of assert statements so the failure output shows you the actual values, not just the line of code that failed.
Asserting an Exception
Some behavior is "this call should fail." For that, use pytest.raises as a context manager:
import pytest
from slug import slugify
def test_slugify_rejects_empty_string():
with pytest.raises(ValueError):
slugify("")
The test passes when the block raises ValueError. It fails if the block runs to completion without raising, or if it raises a different exception type. You can also bind the raised exception to inspect its message:
def test_slugify_rejects_empty_string():
with pytest.raises(ValueError, match="cannot be empty"):
slugify("")
match is a regex applied to the exception's string representation. Useful when you want to make sure the right error message comes through.
Fixtures and Parametrize
pytest has two useful features you'll see later:
- fixtures (for shared setup like a database connection or an HTTP client)
- parametrize (for running the same test against a list of inputs).
We'll add fixtures when we wire up the database and need a fresh session per test.
For now, just assert and pytest.raises are a good start.