Integration & Data

factory_boy versus Plain Fixture Builders

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.3 if it is under consideration; nothing extra for builders.
  • pytest >= 8.0 and, 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.

Python
# 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
Python
# 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
What each approach handles for you A comparison across five capabilities. Unique sequences, related-object creation, ORM session persistence and named traits are built into factory_boy and must be hand-written in a builder. Zero dependencies, immutability and plain-function readability are properties of builders that factory_boy does not offer. Different strengths, not a better and a worse capability factory_boy plain builder unique sequences built in itertools.count related objects SubFactory call another builder ORM persistence session + flush by hand immutability not the model frozen dataclass dependencies one package none
The top three rows are where factory_boy saves real work; the bottom two are where builders are simply better. Persistence is the dividing line.

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. Use flush, as described in rolling back every test with nested transactions.
  • SubFactory chains. Each SubFactory creates 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.

Python
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.

Python
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())
A flag-driven builder versus a named variant Two call sites for the same scenario. The flag form passes overdue equals true and leaves the reader to trust that the builder sets the related fields consistently. The named variant an_overdue_invoice states the scenario in the domain's own words and owns the consistency between status, due date and reminder count. The call site is the documentation flag an_invoice(overdue=True, reminders=2) reader must trust the fields agree and the flag list keeps growing named variant an_overdue_invoice() one place defines "overdue" the test reads as the scenario
When the definition of "overdue" changes, one function changes, and every test using it follows without edits.

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".

Choosing per kind of test data A two-by-two grid by persistence and relation depth. Persisted and related data suits factory_boy. Unpersisted values suit plain builders regardless of depth. Persisted flat data can use either, with consistency with the rest of the suite as the tiebreaker. Persistence decides; depth confirms flat with relations persisted value only either match the rest of the suite factory_boy SubFactory and session binding pay back plain builders frozen dataclasses with replace(), no dependency
The bottom row is the one teams most often get wrong in the other direction, writing factory classes for payloads that would be clearer as a function.

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.

← Back to Test Data Factories & Builders