Every suite eventually has to decide how tests create their data, and the two serious options look similar enough that the choice is often made by whoever writes the first one. factory_boy brings sequences, related-object creation, traits and ORM session handling; plain builder functions bring zero dependencies and nothing to learn. They are good at different things, and most mature suites use both.
Prerequisites
factory_boy >= 3.3if it is under consideration; nothing extra for builders.pytest >= 8.0and, for persisted data, the transactional session from database fixtures and transactional tests.- A clear view of which test data is persisted and which is merely constructed, because that distinction decides most of this.
Solution
Use a builder where the data is a value, and a factory where it is a persisted graph.
# Builders: values, payloads, configuration. No dependency, no session.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class ChargeRequest:
customer_id: str = "cus_test"
amount_minor: int = 1000
currency: str = "GBP"
idempotency_key: str = "key-1"
def a_charge(**overrides) -> ChargeRequest:
# replace() gives an immutable override; a typo is a TypeError here.
return replace(ChargeRequest(), **overrides)
def test_zero_amount_is_rejected(api):
response = api.post("/charges", json=a_charge(amount_minor=0).__dict__)
assert response.status_code == 422
# factory_boy: persisted models with relations, bound to the test session.
import factory
from myapp.models import Customer, Order
class CustomerFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Customer
sqlalchemy_session_persistence = "flush" # never commit
email = factory.Sequence(lambda n: f"customer-{n}@example.test")
country = "GB"
class OrderFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Order
sqlalchemy_session_persistence = "flush"
customer = factory.SubFactory(CustomerFactory) # created and linked for you
status = "open"
def test_open_orders_are_listed(db_session, bind_factories):
OrderFactory.create_batch(3)
OrderFactory(status="cancelled")
assert len(list_open_orders(db_session)) == 3
Why this works
A builder is the smallest thing that separates defaults from overrides. dataclasses.replace copies a frozen instance with a few fields changed, which is exactly the "valid object, plus what this test is about" shape. Because the dataclass is frozen, a test cannot mutate a shared default by accident, and because replace validates field names, a misspelt override fails at the call site.
factory_boy does the same for models and adds the parts that are genuinely tedious by hand: generating unique values per call, creating and linking related objects, and pushing everything through the ORM session so primary keys exist and relationships resolve. Reimplementing those in builders is possible and usually ends up as a smaller, less tested copy of factory_boy.
Edge cases and failure modes
- Factories that commit.
sqlalchemy_session_persistence = "commit"ends the test's transaction. Useflush, as described in rolling back every test with nested transactions. - SubFactory chains. Each
SubFactorycreates a row, so a three-level chain inserts several rows per call. Keep optional relations opt-in. - Builders that grow a parameter per test. A builder with fifteen keyword arguments is a god factory in disguise. Split it into named builders —
an_overdue_invoice(),a_refunded_charge(). - Mutable defaults in builders. A default list or dict shared between calls leaks state between tests. Use
field(default_factory=list)in the dataclass. - Random defaults. Anything asserted on must be deterministic; see generating reproducible fake data with Faker.
Writing a builder that scales
The naive builder — a function with keyword defaults returning a dict — works until the object has nested parts. A small amount of structure keeps it readable as the domain grows.
from dataclasses import dataclass, field, replace
from itertools import count
_ids = count(1)
@dataclass(frozen=True)
class LineItem:
sku: str = "SKU-1"
quantity: int = 1
unit_minor: int = 500
@dataclass(frozen=True)
class OrderPayload:
order_id: str = field(default_factory=lambda: f"ord-{next(_ids)}") # unique
currency: str = "GBP"
lines: tuple[LineItem, ...] = (LineItem(),) # immutable
def an_order(*, lines=None, **overrides) -> OrderPayload:
if lines is not None:
overrides["lines"] = tuple(lines)
return replace(OrderPayload(), **overrides)
def a_line(**overrides) -> LineItem:
return replace(LineItem(), **overrides)
def test_total_across_lines(pricing):
order = an_order(lines=[a_line(quantity=2), a_line(unit_minor=250)])
assert pricing.total(order) == 1250
Tuples rather than lists for nested collections keep the whole structure immutable, and itertools.count gives the one feature of factory sequences that builders most often need. Beyond that, a builder module stays a plain Python file anyone can read in a minute, which is its main advantage over a factory class hierarchy.
Readability at the call site
Whichever mechanism is chosen, the property worth optimising is how a test reads, because tests are read far more often than they are written. Three conventions make a large difference and apply equally to factories and builders.
Name builders after the domain, with an article. an_order(), a_refunded_charge(), an_overdue_invoice() read as prose inside a test and make the scenario obvious. make_order() and order_factory() describe the mechanism rather than the thing.
Pass only what the test is about. If a test is about VAT exemption, the only override should be the exemption. Every additional keyword is a claim that the test depends on that value, and readers take such claims seriously. A test with six overrides where one matters teaches the reader nothing about which one.
Prefer named variants to flags. an_overdue_invoice() is clearer than an_invoice(overdue=True) once the variant involves more than one field, because the builder can set the due date, the status and the reminder count consistently. With factory_boy the same idea is a trait; with builders it is a second function that calls the first.
from datetime import date, timedelta
def an_invoice(**overrides) -> Invoice:
return replace(Invoice(), **overrides)
def an_overdue_invoice(**overrides) -> Invoice:
# One place that knows what "overdue" means: status, date and reminders agree.
defaults = dict(
status="open",
due_on=date(2026, 1, 1) - timedelta(days=30),
reminders_sent=2,
)
return an_invoice(**{**defaults, **overrides})
def test_overdue_invoices_are_escalated(escalation):
assert escalation.should_escalate(an_overdue_invoice())
assert not escalation.should_escalate(an_invoice())
These conventions matter more than the choice of library. A suite of well-named builders reads better than one of carelessly used factories, and the reverse is equally true.
A useful review check follows from all of this. When a pull request adds a test, read only the arrangement lines and ask what scenario they describe. If the answer is obvious from the builder and factory names alone — "an overdue invoice for an exempt customer" — the data layer is doing its job. If the answer requires reading the builder's implementation or counting keyword arguments, the test is about to become one of the ones nobody wants to touch, and a new named variant is cheaper to add now than to extract later. Applied consistently, that single question keeps both factories and builders small, named and honest, which is most of what separates a data layer that helps from one that has to be worked around.
Deciding for a real suite
The choice is rarely all-or-nothing, and a short inventory settles it quickly. List every kind of object tests currently construct, and mark two things for each: whether it is persisted through the ORM, and how many related objects a typical test needs alongside it.
Persisted objects with relations — orders with customers and lines, subscriptions with plans and invoices — are where factory_boy pays back immediately. SubFactory, session binding and create_batch remove exactly the code that makes hand-written setup long and error-prone.
Unpersisted values — request payloads, event messages, configuration, domain value objects — gain nothing from a factory class. A builder is shorter, has no dependency, and is immutable by construction.
Persisted objects without relations sit in the middle, and either works. The tiebreaker is consistency: if the suite already uses factory_boy for the related models, using it for the flat ones too keeps one vocabulary for "things in the database".
Once decided, put both in one test-support module per domain area — tests/support/billing.py holding the billing factories and builders together — so a reader looking for "how do I make an invoice for a test" finds one place regardless of which mechanism is behind it.
Frequently Asked Questions
Is factory_boy worth the dependency for a small project?
Usually not until the project has several related models that tests create together. A dozen builder functions with keyword defaults cover a small domain perfectly well. factory_boy earns its place when related-object creation, sequences and traits would otherwise be reimplemented by hand in each builder.
Can factories and builders coexist in one suite?
Yes, and they often should: factory_boy for persisted ORM models, plain builders for value objects, request payloads and configuration. The split follows persistence — anything that must be flushed through a session benefits from factory_boy's session handling.
Do builders need to be pytest fixtures? No. A builder is an ordinary function a test calls with its own arguments. Making it a fixture removes the ability to pass per-test overrides without a factory-as-fixture indirection, which is extra ceremony for no benefit.
Related
- Test Data Factories & Builders — traits, sequences and the decay modes of both approaches.
- Generating Reproducible Fake Data with Faker — keeping generated values deterministic.
- Wiring Test Doubles Through a Factory Function — the same defaults-plus-overrides idea for collaborators.
- Designing Strategies for Domain Data — when the goal is exploring inputs rather than filling fields.
← Back to Test Data Factories & Builders