Skip to content

← Testing with pytest and Hypothesis step 16 of 19

Hard Primitives

Build a Typed Factory Fixture

A factory fixture returns a function that a test calls to make records, each with its own values. Here you write that function in plain Python, without pytest. Its type is the MakeUser callback protocol from the starter, so mypy checks every call to it.

make = user_factory()
original = ["staff"]
first = make(name="Ada", tags=original)
second = make(name="Lin", age=24)
first.tags.append("reviewer")

assert first.id == 1
assert second.id == 2
assert second.tags == []
assert original == ["staff"]
assert user_factory()(name="New").id == 1

What to write

Write user_factory() -> MakeUser. It returns a function that takes only keyword arguments and returns a User:

  • name: str has no default, so every call must pass it.
  • age: int defaults to 30.
  • tags: Sequence[str] defaults to the empty tuple ().

Follow these rules:

  • The first user from a factory gets the ID 1. Each later call to the same factory adds one.
  • Each factory counts on its own. A new factory starts at 1 again, so IDs are unique within one factory, not across factories.
  • Keep the name, the age and the tags as given. Keep the tags in order, duplicates included. Do not validate names or ages.
  • Give every user a new list of tags, even when the caller passes a list. Changing one user's tags must not change another user's tags or the caller's sequence. Changing the caller's list later must not change a user.

Why a protocol, and why a copy

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

Callable[..., User] would check only the result. This protocol keeps the keyword names, so mypy rejects nmae="Ada", a call with no name, and a str passed as age. Submit runs mypy, so the function you return must accept every call the protocol allows. mypy checks that age and tags have defaults, but not what the defaults are. The tests check the values, so use 30 and ().

User is a frozen dataclass, so user.name = "Bo" raises FrozenInstanceError. Freezing stops you from assigning a new value to a field, but not from changing the list a field holds. user.tags.append(...) still works, which is why each user needs its own list.

Edge cases

  • An age of 0 is fine, because you do not validate ages.
  • A string is a sequence of strings, so a direct call with tags="ab" gives the tags ["a", "b"]. The driver always passes lists.

What the driver reports

solve makes one factory and runs calls in order. It returns one dictionary per call, with the keys id, name, age and tags. A call that leaves out age or tags gets your defaults. An empty calls list gives an empty result.

After each call, the driver copies the user's tags into that call's row. If mutate is true, it then appends "MUTATED" to the user's own list before the next call. The driver copied the row first, so that row never shows the marker. If two users share one list, a later row does.

The driver cannot catch every sharing bug, so test the other cases yourself. Make two users from one input list, change that list after the calls, and make two separate factories.