Most projects that adopt mypy --strict adopt it for src/ and exclude tests/. The reasoning is always the same: tests are throwaway, the annotations are noise, and getting them green is a week of work for no benefit.
Here is the benefit.
# src/billing.py
def charge(customer_id: str, cents: int) -> Receipt: ...
# tests/test_billing.py — untyped
def test_charge():
assert charge(customer="cus_1", amount=500).status == "ok"
Rename customer_id to customer and cents to amount in production, and this test is correct. Rename them back, or rename only one, and the test is broken — but mypy is not looking at it, so you find out when the test runs, in the same run as forty other failures, with a TypeError rather than a name.
Worse is the other direction. Change charge to return Result[Receipt, ChargeError] instead of Receipt. Production callers all fail type-checking immediately. The test does not, because .status on an unknown object is unknown. It fails at runtime with AttributeError, in the middle of a refactor, and it looks like your new code is broken rather than your old test.
An untyped test suite is an unversioned second copy of your API. It drifts, and nothing checks the drift.
pytest’s public types
pytest exports real types for its built-in fixtures. Use them:
from pathlib import Path
import pytest
def test_writes_a_report(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
request: pytest.FixtureRequest,
) -> None:
...
Two of these have sharp edges.
capsys: pytest.CaptureFixture[str] — the parameter is mandatory. CaptureFixture is generic over the captured type (str for capsys, bytes for capsysbinary), and --disallow-any-generics, which is part of --strict, rejects the bare form. This is the single most common first error when you turn strict on for a test directory, and the fix is one word.
request.param is Any. There is no way for pytest to know what a fixture was parametrised with, so the type system gives up at that boundary. Narrow it immediately rather than letting Any spread through the fixture body:
@pytest.fixture(params=["sqlite", "postgres"])
def backend(request: pytest.FixtureRequest) -> Iterator[Database]:
name: str = request.param # the Any stops here
with make_database(name) as db:
yield db
Also worth knowing: pytest.Pytester for testing plugins, and pytest.Config when you write a hook.
💡A yield fixture returns the object the test receives. So why is it annotated Iterator[T] rather than T?
click to reveal
Because the annotation describes the function you wrote, and the function you wrote is a generator.
@pytest.fixture
def session(engine: Engine) -> Iterator[Session]:
conn = engine.connect()
yield Session(bind=conn)
conn.close()
session contains a yield, so calling it returns a generator object, not a Session. Annotating it -> Session is simply false, and mypy says so — error: The return type of a generator function should be "Generator" or one of its supertypes. It is the most common --strict error in a freshly-typed suite, and people usually “fix” it by adding a # type: ignore, which is the worst of all options because it silences a correct diagnostic.
The reason it feels wrong is that pytest performs a transformation: it drives the generator, hands the yielded value to the test, and resumes it afterwards. But that is pytest’s behaviour at runtime, and the type checker is checking your source, not pytest’s semantics. The test function’s own parameter is what gets annotated Session:
def test_something(session: Session) -> None: ...
Two annotations, two different things, both correct. Iterator[Session] is what the fixture is; Session is what the test receives.
Generator[Session, None, None] is equivalent and older; Iterator[Session] is shorter and says the same thing for a fixture that neither receives sent values nor returns one.
The hard case: a factory fixture
The typing problem that actually requires thought is the factory. Start from what people write:
@pytest.fixture
def make_user() -> Callable[..., User]:
def _make(*, name: str, age: int = 30, tags: Sequence[str] = ()) -> User: ...
return _make
Callable[..., User] type-checks make_user(name="ada"). It also type-checks make_user(nmae="ada", age="old", colour=7), and make_user(1, 2, 3), and make_user(). The ... means any parameters at all: you have written down the return type and discarded everything a caller could get wrong, which is most of what you wanted checked.
Callable has no syntax for keyword-only parameters or defaults. It never will — the shape is positional. What does have that syntax is a callback Protocol:
class MakeUser(Protocol):
def __call__(
self, *, name: str, age: int = 30, tags: Sequence[str] = ()
) -> User: ...
@pytest.fixture
def make_user() -> MakeUser:
next_id = 0
def _make(*, name: str, age: int = 30, tags: Sequence[str] = ()) -> User:
nonlocal next_id
next_id += 1
return User(id=next_id, name=name, age=age, tags=list(tags))
return _make
Now make_user(nmae="ada") is an error at the call site, in the test file, before anything runs. The Protocol is checked structurally against the closure, so if the two drift apart mypy says so.
This is the standard shape for any fixture returning a callable, and it is worth internalising because the same trick types decorators, hooks, callbacks and dependency-injection registries.
pytest.raises gives you a typed handle
with pytest.raises(CardDeclinedError) as exc_info:
charge(customer_id="cus_1", cents=500)
assert exc_info.value.decline_code == "insufficient_funds"
exc_info is an ExceptionInfo[CardDeclinedError], so exc_info.value is a CardDeclinedError and .decline_code is checked. Add an attribute to the exception class, misspell it here, and mypy catches it — which is the whole point of putting structured attributes on exceptions instead of encoding them in the message.
💡The team agrees to type the tests, runs mypy, gets 900 errors, and someone proposes a per-module override to relax strictness for tests/* "just to get green, we will tighten it later". What actually happens?
click to reveal
It never gets tightened, and the reason is structural rather than a failure of will.
On the day you add the override, the 900 errors are a known, bounded quantity attached to a specific decision. A month later the suite has grown by 200 tests written under the relaxed setting, so the number is 1,100 and none of the new ones were anybody’s fault. A year later it is a project. There is no moment at which removing the override is cheaper than it was on day one, and every day it is slightly more expensive — which is the definition of a ratchet pointed the wrong way.
The second-order effect is worse than the count. A relaxed test directory teaches the codebase that annotations are optional in tests, so helper modules under tests/ — factories, builders, custom assertions, the shared conftest.py — get written untyped too. Those are libraries, used by hundreds of tests, and they are now the least-checked code in the repo while being the most depended-upon.
What works instead is to make the ratchet point the other way. Turn strict on for the whole test tree, and use a file-level, temporary, listed exclusion for the modules you have not converted — a list in pyproject.toml that shrinks. A list of 40 filenames that becomes 30 next sprint is visible progress and creates pressure; a single [[tool.mypy.overrides]] block with module = "tests.*" creates none, because it never changes.
And convert in an order that pays immediately: conftest.py first, then shared helpers, then the test files touching the API you change most often. Those three groups are where a type error would actually have caught something, and getting them done is most of the value regardless of what the total error count says.
The short version
Type the tests for the same reason you type the source: so that a rename is a compile-time event. The annotations are not documentation, and they are not noise. They are the only thing connecting the tests you wrote to the API you have now.