Ch. 22 · Software Testing

Test Data Builders and Object Mothers

Replace sprawling inline fixtures with a test data builder in Python, compare it with an object mother, and avoid the mystery-guest default.

~7 min readintermediateupdated Oct 19, 2026

Every non-trivial test needs data, and domain objects in real systems need a lot of it. An order has an ID, a customer, a shipping country, line items, a timestamp, a currency, maybe a coupon. A test about shipping fees cares about two of those. If each test spells out all seven, the two that matter are buried, and the day the model gains an eighth required field, every one of those tests breaks at once.

Test data builders and object mothers are the two standard answers. Interviewers like asking about them because the trade-offs are concrete and the failure modes are ones everybody has seen.

The problem with inline construction

Here’s the model, in Python with frozen dataclasses. Orders validate themselves on construction:

from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal


@dataclass(frozen=True)
class LineItem:
    sku: str
    qty: int
    unit_price: Decimal


@dataclass(frozen=True)
class Order:
    id: str
    customer_id: str
    country: str
    items: tuple[LineItem, ...]
    placed_at: datetime
    status: str = "placed"
    coupon: str | None = None

    def __post_init__(self):
        if not self.items:
            raise ValueError("an order needs at least one item")
        if self.placed_at.tzinfo is None:
            raise ValueError("placed_at must be timezone-aware")


def shipping_fee(order: Order) -> Decimal:
    subtotal = sum((i.unit_price * i.qty for i in order.items), Decimal("0"))
    if order.country == "US" and subtotal >= Decimal("50"):
        return Decimal("0")
    return Decimal("5.99") if order.country == "US" else Decimal("14.99")
python

A test written directly against the constructor:

class ShippingFeeInline(unittest.TestCase):
    def test_free_shipping_is_us_only(self):
        order = Order(
            id="ord-7",
            customer_id="cus-3",
            country="DE",
            items=(LineItem("MUG", 10, Decimal("10.00")),),
            placed_at=datetime(2026, 3, 1, 9, 0, tzinfo=timezone.utc),
        )
        self.assertEqual(shipping_fee(order), Decimal("14.99"))
python

It passes. But read it as a newcomer would. Do ord-7 and cus-3 matter? Does the date? You can’t tell without reading shipping_fee. The test is precise about everything, so it communicates nothing about what’s essential.

Now the business adds multi-currency support and makes currency a required field on Order. The small suite for this article has five tests that create orders, and the next run reported:

TypeError: Order.__init__() missing 1 required positional argument: 'currency'
Text

five times, once per test. In a real codebase it’s 50 or 500, spread across files owned by different teams, and none of those tests has anything to do with currency.

A test data builder

A builder holds one valid, unremarkable default object and offers methods to change the parts a test cares about:

from dataclasses import replace
from datetime import datetime, timezone
from decimal import Decimal

from orders import LineItem, Order


class OrderBuilder:
    def __init__(self):
        self._order = Order(
            id="ord-1",
            customer_id="cus-1",
            country="US",
            items=(LineItem("TEE-M", 1, Decimal("20.00")),),
            placed_at=datetime(2026, 3, 1, 9, 0, tzinfo=timezone.utc),
        )

    def shipped_to(self, country):
        self._order = replace(self._order, country=country)
        return self

    def with_item(self, sku, qty=1, unit_price="10.00"):
        item = LineItem(sku, qty, Decimal(unit_price))
        self._order = replace(self._order, items=(*self._order.items, item))
        return self

    def with_only_item(self, sku, qty=1, unit_price="10.00"):
        self._order = replace(self._order, items=(LineItem(sku, qty, Decimal(unit_price)),))
        return self

    def with_coupon(self, code):
        self._order = replace(self._order, coupon=code)
        return self

    def build(self):
        return self._order


def an_order():
    return OrderBuilder()
python

The tests now say what they’re about:

class ShippingFee(unittest.TestCase):
    def test_us_orders_of_exactly_50_ship_free(self):
        order = an_order().shipped_to("US").with_only_item("MUG", qty=5, unit_price="10.00").build()
        self.assertEqual(shipping_fee(order), Decimal("0"))

    def test_us_orders_under_50_pay_standard_rate(self):
        order = an_order().shipped_to("US").with_only_item("MUG", qty=1, unit_price="49.99").build()
        self.assertEqual(shipping_fee(order), Decimal("5.99"))

    def test_free_shipping_is_us_only(self):
        order = an_order().shipped_to("DE").with_only_item("MUG", qty=10, unit_price="10.00").build()
        self.assertEqual(shipping_fee(order), Decimal("14.99"))

    def test_builder_default_is_a_valid_order(self):
        self.assertEqual(shipping_fee(an_order().build()), Decimal("5.99"))
python

All four pass under python3 -m unittest -v. Each test names exactly two things, the country and the items, because those are the inputs to the rule. IDs and timestamps exist, but the test doesn’t mention them, which tells the reader they don’t matter.

When currency became required, the builder’s tests failed with the same TypeError as everything else. The fix was one line in the builder’s default (currency="USD",), after which all four builder-based tests passed again. The inline test still failed until someone edited it by hand. That’s the maintenance argument for builders in one example: knowledge of how to construct a valid order lives in one place.

Design choices in the builder

The default must be valid and boring. One item, a US address, a fixed UTC timestamp. The last test above, test_builder_default_is_a_valid_order, exists to keep it that way. If the default ever violates validation, that test says so directly instead of 40 tests failing confusingly.

Use replace so validation runs. dataclasses.replace creates a new instance through __init__, so __post_init__ runs for every change. A builder can’t quietly produce an order with no items; replace(an_order().build(), items=()) raises ValueError: an order needs at least one item. If a test genuinely needs invalid data (to test the validation itself), it should construct it explicitly, so the intent is obvious.

Fixed, not random, defaults. Some teams fill defaults with random values from a fake-data library. That can find bugs, but it also makes failures hard to reproduce. If you do it, seed the generator and print the seed when a test fails.

A fresh builder per test. an_order() returns a new builder each call. A builder stored at module level and shared between tests would carry one test’s changes into the next.

Name methods after the domain, such as shipped_to and with_coupon, rather than generic setters. The test then reads close to the requirement it checks.

In Python, a function with keyword overrides (make_order(country="DE") built on replace) is a lighter version of the same idea and is often enough. The class form earns its place when there are nested objects to build, like line items, or when helper methods (with_only_item) encode non-trivial setup.

Object mothers, and where they go wrong

An object mother is a module of factory functions that return ready-made objects for named scenarios: a_paid_order(), an_international_order(), an_order_with_coupon(). According to Martin Fowler, the name was coined on a Thoughtworks project around 2000. It’s attractive because the names carry meaning, and a test like shipping_fee(an_international_order()) is short.

The problem appears with combinations. A test needs a paid international order with a coupon. Either someone writes a_paid_international_order_with_coupon(), or they take a_paid_order() and modify it in the test. Over time the mother module grows to dozens of functions with overlapping names, nobody is sure which one has which properties, and changing one (say, a_paid_order gets a second item) breaks tests that depended on details its name didn’t mention.

Builders handle combinations by composition: an_order().paid().shipped_to("DE").with_coupon("SPRING10").build(). The common middle ground is a builder with a few named starting points, where the mother functions return builders rather than finished objects: a_paid_order().shipped_to("DE"). You keep the readable scenario names and the ability to tweak them.

The mystery guest

Builders introduce one trap of their own. This test passes:

    def test_mystery_guest(self):
        # Relies on the builder's default country without saying so.
        order = an_order().with_only_item("MUG", qty=5, unit_price="10.00").build()
        self.assertEqual(shipping_fee(order), Decimal("0"))
python

It expects free shipping, which only applies in the US, but it never says “US”. It works because the builder’s default happens to be US. If someone later changes the default to "GB", perhaps because most new tests are for the UK launch, this test fails, and the person who changed the default has to work out why a test that never mentioned a country cares about it.

The rule: if the expected result depends on a value, set that value in the test, even when it equals the default. shipped_to("US") in the earlier tests looks redundant, and it isn’t. It documents the input and protects the test from changes to defaults. Builder defaults should only cover things the test genuinely doesn’t depend on.

Practice exercise: 120 tests and a new required field

Your team is adding tax_region as a required field on Invoice. A search finds 120 test files that call Invoice(...) directly, each listing between eight and twelve fields, and a fixtures.py with 34 functions like paid_invoice(), overdue_invoice() and overdue_invoice_with_partial_payment(). How would you handle the field change, and what would you change about the test data approach?

Answer, with reasoning

For the field itself, I wouldn’t edit 120 files by hand in the same pull request as the feature. I’d first introduce an InvoiceBuilder with a valid default (including a sensible tax_region, say the company’s home region) and a test that the default builds. Then I’d migrate the direct constructions to the builder in mechanical, behaviour-preserving pull requests, file by file, keeping in each test only the fields that its assertions depend on. Tests that check tax behaviour must set tax_region explicitly rather than relying on the default, so they don’t become mystery guests. The feature PR then only touches the model, the builder default and the tax tests.

For the 34 fixtures, I’d turn the common ones into builder starting points (a_paid_invoice() returning a builder), so overdue_invoice_with_partial_payment() becomes an_overdue_invoice().with_payment("40.00"), and delete variants once nothing uses them. I’d keep scenario names for the handful that appear in many tests, since they make tests readable, but make sure each name describes everything a caller can rely on. The result is that the next new required field is a one-line change, and each test reads like a statement of the rule it checks.

More in Software Testing

read ✓Software Testing · mid

Testing Error Paths

Test failure cases on purpose: assert the exact exception and message, probe edge inputs like NaN, and check that failures leave no damage.

~7 min readread →
read ✓Software Testing · mid

Red, Green, Refactor

A real TDD session in Python's unittest: watch each test fail for the right reason, pass with minimal code, then refactor safely under green.

~6 min readread →
read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
esc