Kodokon kodokon.com

Testing with pytest: fixtures, parametrization, best practices

Structure robust tests with pytest: fixture injection, yield-based teardown, table-driven parametrization and the pitfalls of broad scopes.

9 min · 3 questions

Open this lesson in Kodokon

pytest rests on two strong ideas: discovery by convention (test_*.py files, test_* functions) and the plain assert, whose introspection displays the actual values of both sides on failure - no more memorizing assertEqual and its thirty variants. Two tools to know right away: pytest.raises to check that an exception is indeed raised, and pytest.approx to compare floats without fighting binary rounding.

PYTHON
import pytest

def divide(a: float, b: float) -> float:
    return a / b

def test_nominal() -> None:
    assert divide(8, 4) == 2.0

def test_zero_division() -> None:
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

def test_floats() -> None:
    assert 0.1 + 0.2 == pytest.approx(0.3)
Without approx, 0.1 + 0.2 == 0.3 is false in binary floats.

Fixtures are pytest's dependency injection: declare a parameter bearing the name of a fixture, and pytest builds it then injects it. The yield separates setup from teardown - the code after it runs even if the test fails. The scope parameter (function by default, module, session) controls the object's lifetime. Put shared fixtures in a conftest.py: they become available throughout the folder, without a single import.

PYTHON
import sqlite3

import pytest

@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (name TEXT)")
    yield conn
    conn.close()

def test_insert(db) -> None:
    db.execute("INSERT INTO users VALUES ('Ada')")
    query = "SELECT name FROM users"
    rows = db.execute(query).fetchall()
    assert rows == [("Ada",)]
conn.close() runs after the test, whether it passes or fails.

@pytest.mark.parametrize turns a test into a table of cases: each tuple becomes an independent test, named and filterable individually. Stack several parametrize decorators to get the cartesian product of the parameters. Give your cases readable ids via pytest.param(..., id='...') - a failure report should be understandable without opening the source file.

PYTHON
import pytest

def slugify(text: str) -> str:
    return "-".join(text.lower().split())

@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("Hello World", "hello-world"),
        ("  Python  rocks ", "python-rocks"),
        pytest.param("", "", id="empty"),
    ],
)
def test_slugify(raw: str, expected: str) -> None:
    assert slugify(raw) == expected
Three cases, three distinct tests in the report.
BASH
pytest -x --lf
pytest -k "slugify and not empty"
pytest -q --durations=5
--lf reruns only the failures; --durations reveals the slow tests.

Knowledge check

Make sure you remember the key points of this lesson.

  1. How does pytest know which fixture to supply to a test?
    • Thanks to an @inject decorator placed on the test
    • By the order in which fixtures are declared in the file
    • By the parameter's type annotation
    • By the parameter's name: if it matches a visible fixture (module or conftest.py), pytest builds it and injects it
  2. In a fixture, what is the code placed after the yield for?
    • It's the teardown: it runs after the test, even if the test failed
    • It never runs, the yield ends the fixture
    • It provides a fallback value if the setup fails
    • It runs only if the test fails
  3. What does the command pytest --lf do?
    • It lists the fixtures available in the project
    • It reruns only the tests that failed on the last run
    • It stops the session at the first failure encountered