Skip to content

Intro to pytest

pytest has become a widely used test framework for Python.


Install

pytest is a development dependency:

shell
uv add --dev pytest

Test Discovery

pytest finds tests by convention. Drop them in a tests/ directory at the project root:

Text
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__.py is needed in tests/.

Anything matching those names gets picked up automatically. Anything else is ignored.

A First Test

Say we have a slugify function in slug.py:

slug.py
def slugify(name: str) -> str:
    return name.strip().lower().replace(" ", "-")

The test goes in tests/test_slug.py:

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

shell
uv run pytest

When everything passes:

Text
============================= 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:

Text
=================================== 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:

Python
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:

Python
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.