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
Hide 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. Withdisallow_untyped_defs = false, mypy accepts their unannotated tests and still checks the calls inside them. - mypy's
excludesetting 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.