The pytest Fixture Builder I Cannot Live Without
A four-stage tour of the fixture-builder pattern: a callable factory that mints test models with sensible defaults, layered overrides, deterministic IDs, and per-test isolation. The shape I paste into every conftest.py.
By @yunatorres
April 30, 2026
·
Updated May 18, 2026
687 views
2
4.3 (14)
from __future__ import annotations
import itertools
from typing import Any
_id_counter = itertools.count(1)
def make_user(**overrides: Any) -> dict:
base = {
'id': next(_id_counter),
'email': '[email protected]',
'name': 'Test User',
'role': 'member',
'verified': True,
}
base.update(overrides)
return base
if __name__ == '__main__':
print(make_user())
print(make_user(name='Ada', role='admin'))
print(make_user(email='[email protected]'))The smallest version of the pattern is a function that returns a fresh dict every call, with sensible defaults that only matter when the test does not care about them. The id counter is module-level so two calls in the same test produce different ids without anyone having to remember to pass them. I have seen plenty of test suites use class-based factories from factory_boy for the same job; for unit tests I prefer the plain function because there is no metaclass, no Meta, and no debugger surprise. When the override is just name='Ada', you read the test and know exactly what is being exercised.
from __future__ import annotations
import itertools
# This file shows what conftest.py would look like.
# We simulate pytest's fixture-yield contract here so the demo runs standalone.
def user_factory_fixture():
counter = itertools.count(1)
created: list = []
def make_user(**overrides):
base = {
'id': next(counter),
'email': f'user{next(counter)}@example.com',
'name': 'Test User',
'role': 'member',
}
base.update(overrides)
created.append(base)
return base
make_user.created = created
return make_user
# Pretend we are inside two separate tests; each calls the fixture fresh.
def test_one():
make = user_factory_fixture()
a = make()
b = make(role='admin')
print('test_one created:', len(make.created), 'users')
print('roles:', [u['role'] for u in make.created])
def test_two():
make = user_factory_fixture()
make()
print('test_two created:', len(make.created), 'users')
print('counter restarted:', make.created[0]['id'])
test_one()
test_two()Rebinding the counter and the created list to fixture-local closures is the move that gives per-test isolation without any teardown plumbing. In a real pytest setup you write @pytest.fixture over user_factory_fixture and yield make_user; the fixture is recreated for every test that depends on it because the default scope is function. The make.created list attached to the function is a tiny but useful trick: assertion code can do assert len(make.created) == 3 without inventing a tracking variable. Stage-one's module-level counter would have leaked test ids across tests, which was the bug I wrote stage two to fix.
from __future__ import annotations
import itertools
from typing import Any, Callable
def make_factories() -> dict:
user_ids = itertools.count(1)
org_ids = itertools.count(100)
def make_org(**overrides: Any) -> dict:
base = {'id': next(org_ids), 'name': 'Acme', 'plan': 'pro'}
base.update(overrides)
return base
def make_user(org=None, **overrides: Any) -> dict:
if org is None:
org = make_org()
base = {
'id': next(user_ids),
'email': '[email protected]',
'org_id': org['id'],
'role': 'member',
}
base.update(overrides)
return base
def make_admin(**overrides: Any) -> dict:
return make_user(role='admin', **overrides)
return {'org': make_org, 'user': make_user, 'admin': make_admin}
if __name__ == '__main__':
f = make_factories()
org = f['org'](plan='enterprise')
print('org:', org)
print('admin:', f['admin'](org=org, email='[email protected]'))
print('lone user:', f['user']()) # gets a fresh org auto-assignedReal test data has shape: a user belongs to an org, an order belongs to a user, a payment belongs to an order. Naming each factory and letting them call each other gives you the equivalent of factory_boy's SubFactory, but explicit. The make_admin shortcut is the part I always end up wanting: it is just make_user(role='admin', ...) but using it in a test reads as the_admin = factories['admin'](), which signals intent. Auto-assigning a fresh org when the caller does not pass one keeps trivial tests trivial; passing org=org keeps related-data tests honest about which records share a parent.
from __future__ import annotations
import itertools
from typing import Any
def make_factories(freeze: bool = False) -> dict:
if freeze:
# Deterministic ids so snapshot tests do not churn on every run.
user_id = lambda: 1
org_id = lambda: 100
time_str = lambda: '2024-01-01T00:00:00Z'
else:
user_seq = itertools.count(1)
org_seq = itertools.count(100)
user_id = lambda: next(user_seq)
org_id = lambda: next(org_seq)
time_str = lambda: '2024-01-01T00:00:00Z' # also frozen for tests
def make_org(**overrides: Any) -> dict:
base = {'id': org_id(), 'name': 'Acme', 'plan': 'pro', 'created_at': time_str()}
base.update(overrides)
return base
def make_user(org=None, **overrides: Any) -> dict:
if org is None:
org = make_org()
base = {
'id': user_id(),
'email': '[email protected]',
'org_id': org['id'],
'role': 'member',
'created_at': time_str(),
}
base.update(overrides)
return base
return {'org': make_org, 'user': make_user}
if __name__ == '__main__':
print('--- normal mode (incrementing) ---')
normal = make_factories()
print(normal['user']())
print(normal['user']())
print('--- frozen mode (snapshot-friendly) ---')
frozen = make_factories(freeze=True)
print(frozen['user']())
print(frozen['user']()) # identical to the previous oneSnapshot tests blow up the moment a generated id rolls forward. The fix is a freeze=True flag that pins the id and timestamp generators to constants, which lets snapshots compare equal across runs. I keep both modes available rather than always-frozen because non-snapshot tests benefit from unique ids: when an assertion looks at len({u['id'] for u in users}) you want it to fail loudly if your code accidentally reuses an id. The full builder, four stages later, is what I paste into a conftest.py and never think about again until a junior asks why our tests do not flake on dates.
