Skip to content
← All Python articles

Python explainer

Type-Check Your Tests and Fixtures

About 4 min to read. Allow extra time to work through examples.

Run mypy on your tests as well as your code. Annotate pytest's built-in fixtures, type yield and factory fixtures, and check request.param before you trust it.

This article uses Python. You can make it your practice mode to browse related content across the site.

You rename a parameter of make_order from qty to quantity and fix the calls you can find. You miss one, in an integration test that skips on your laptop because it needs a database. Your local run is green, and CI fails twenty minutes later.

mypy reads code without running it, so it checks skipped tests too:

from dataclasses import dataclass


@dataclass
class Order:
    sku: str
    quantity: int


def make_order(sku: str, quantity: int = 1) -> Order:
    return Order(sku, quantity)


def test_bulk_order() -> None:
    order = make_order("A-1", qty=5)
    assert order.quantity == 5
test_orders.py:15: error: Unexpected keyword argument "qty" for "make_order"  [call-arg]

mypy can catch this only when it reads your tests. Name the tests folder in the command, as in mypy --strict src tests, and run the same command in CI.

Annotate the fixtures pytest gives you

A test function returns nothing, so annotate it -> None. Give each parameter the type of the value pytest passes in. pytest exports a type for each built-in fixture:

Fixture Type
tmp_path pathlib.Path
capsys pytest.CaptureFixture[str]
capsysbinary pytest.CaptureFixture[bytes]
monkeypatch pytest.MonkeyPatch
caplog pytest.LogCaptureFixture
request pytest.FixtureRequest
from pathlib import Path

import pytest


def test_report(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
    report = tmp_path / "report.txt"
    report.write_text("ready", encoding="utf-8")
    print(report.read_text(encoding="utf-8"))
    assert capsys.readouterr().out == "ready\n"

Write CaptureFixture[str], not a bare CaptureFixture. Strict mode reports a generic class that is missing its type argument.

mypy does not know how pytest matches a parameter to a fixture. It trusts your annotation. If a user fixture returns a dict and the test says user: User, mypy reports nothing, and the test fails when it runs. Copy each test's annotation from the fixture's return type.

A yield fixture returns an iterator

A fixture that uses yield is a generator function, so calling it gives an iterator. Annotate the fixture with Iterator[...]. pytest passes the test the one value the fixture yields, so the test parameter gets the plain type:

from collections.abc import Iterator
from io import StringIO

import pytest


@pytest.fixture
def stream() -> Iterator[StringIO]:
    with StringIO("ready") as handle:
        yield handle


def test_stream(stream: StringIO) -> None:
    assert stream.read() == "ready"

Generator[StringIO, None, None] also works, but a fixture has no use for its two extra arguments.

Check your understanding A fixture is annotated -> Iterator[Session]. What annotation goes on the test parameter that receives it? Show answer

Session. The fixture is a generator function, so it returns an iterator, but pytest passes the test the single value the fixture yields. With Iterator[Session] on the test, mypy rejects every Session method the test calls. With no annotation, strict mode reports an untyped function.

Check request.param before you trust it

A parametrized fixture reads its current value from request.param, which pytest types as Any. mypy checks nothing about an Any value. Strict mode does flag return request.param in a fixture that promises a str. Writing name: str = request.param silences the error and still checks nothing. Assign the value to an object variable and narrow it instead:

import pytest


@pytest.fixture(params=["sqlite", "postgres"])
def backend_name(request: pytest.FixtureRequest) -> str:
    value: object = request.param
    if not isinstance(value, str):
        raise TypeError(f"backend name must be a str, got {value!r}")
    return value


def test_backend_name(backend_name: str) -> None:
    assert backend_name in {"sqlite", "postgres"}

After the isinstance check, mypy knows value is a str, and a wrong param raises TypeError during setup.

Type a factory fixture with a callback protocol

A factory fixture returns a function, and each test calls it to make the objects it needs. If you type the factory as Callable[..., User], mypy checks the result, but the ... lets any arguments through. A callback protocol, a Protocol whose only method is __call__, keeps the parameter names and defaults:

from dataclasses import dataclass
from typing import Protocol

import pytest


@dataclass(frozen=True)
class User:
    id: int
    name: str
    age: int


class MakeUser(Protocol):
    def __call__(self, *, name: str, age: int = 30) -> User: ...


@pytest.fixture
def make_user() -> MakeUser:
    next_id = 0

    def make(*, name: str, age: int = 30) -> User:
        nonlocal next_id
        next_id += 1
        return User(id=next_id, name=name, age=age)

    return make


def test_users(make_user: MakeUser) -> None:
    assert make_user(name="Ada") == User(1, "Ada", 30)
    assert make_user(name="Lin", age=24) == User(2, "Lin", 24)

mypy now rejects make_user(nmae="Ada"), a call with no name, and age="30". It also checks that make fits MakeUser. It checks that age has a default, but not that the default is 30, so the first assertion checks the value.

A factory that takes one positional argument needs no protocol, because Callable[[str], User] describes it. Use a protocol when callers pass keywords or rely on defaults. The next exercise builds a factory like this one.

pytest.raises gives you a typed exception

import pytest


class CardDeclinedError(Exception):
    def __init__(self, decline_code: str) -> None:
        self.decline_code = decline_code
        super().__init__(decline_code)


def test_decline_code() -> None:
    with pytest.raises(CardDeclinedError) as info:
        raise CardDeclinedError("insufficient_funds")
    assert info.value.decline_code == "insufficient_funds"

info is an ExceptionInfo[CardDeclinedError], so mypy knows the type of info.value and reports a misspelled info.value.decline_cod. pytest fills in info.value when the with block ends, so read it after the block. In a real test, call the code that should raise instead of raising by hand.

Going further

  • On an existing suite, start by typing the helpers that many tests call. Strict mode checks the bodies of unannotated tests too, so one typed helper checks every call to it.
  • A [[tool.mypy.overrides]] section can relax strict settings for old test modules while you convert them. With disallow_untyped_defs = false, mypy accepts their unannotated tests and still checks the calls inside them.
  • mypy's exclude setting only changes which files mypy finds in a folder. mypy still checks an excluded file that a checked file imports, or that you name on the command line.

step 15 of 19

Testing with pytest and Hypothesis

Up next Build a Typed Factory Fixture