Skip to content

← Tests That Earn Their Keep step 15 of 19

Hard Primitives

A typed factory fixture

A test suite excluded from type checking lets you rename a parameter, change a return type, or delete a field, and still see green — because the tests were never checked against the signatures they call. The suite becomes a second, unversioned copy of your API that drifts quietly out of date.

So type the tests. The single hardest thing to type in a real suite is the factory fixture, and this is it.

What to write

def user_factory() -> MakeUser

It returns a callable that builds User records:

  • keyword-only, name required, age defaulting to 30, tags defaulting to empty,
  • id starting at 1 and increasing by one per call, per factory — the monotonic ids a real suite needs so two users are never accidentally equal,
  • and each User gets its own tags list.

solve is provided; it builds one factory, replays a list of calls, and — when mutate is set — appends "MUTATED" to each returned tags list before making the next call.

That last part is the assertion that matters. A factory holding one default list and handing the same object to every caller passes every test anyone writes until the day one test mutates what it was given, and then a completely different test starts failing. Build the list fresh each time, or copy what the caller passed. frozen=True on the dataclass does not save you — it freezes the binding, not the list behind it.

Why Callable[..., User] is not good enough

The obvious annotation is Callable[..., User]. It type-checks make(name="x"), and it equally type-checks make(nmae="x", age="old", colour=7), because ... means “any parameters at all”. You have written down the return type and thrown away everything a caller could get wrong.

Callable has no syntax for keyword arguments or defaults. A callback Protocol does:

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

Now make(nmae="x") is an error at the call site, in the test file, before anything runs. This is the standard shape for any fixture that returns a factory, and it is why --disallow-any-generics is in --strict: a bare Callable is exactly the hole it is there to close.

The rest of the typed-pytest vocabulary

Same discipline, in the fixtures you already use — tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest, and capsys: pytest.CaptureFixture[str], where the parameter is mandatory because --disallow-any-generics rejects a bare CaptureFixture. A yield fixture is annotated Iterator[T], never T — the most common --strict error in a test suite that has just been turned on. And with pytest.raises(MyError) as exc_info: gives you ExceptionInfo[MyError], so exc_info.value.code is checked rather than guessed.

What you must not do is add a per-module mypy override to make the tests green. It removes the benefit permanently, and nobody ever takes it back out.