Mastery of TDD

Test Data Builders

Build test scenarios as composable, readable, compiler-checked code. Learn the Builder, Object Mother, and Scenario Factory patterns that turn test setup into a domain-language API.

Most test suites rot because of their setup.

A test has ten lines of hand-assembled objects at the top, magic strings scattered through the middle, and an assertion hiding at the bottom. The behavior under test — the point of the whole file — is lost in scaffolding. When the domain changes, every test’s setup has to change with it. When a new developer joins, they can’t tell which values matter and which are filler.

Test Data Builders fix this. They turn test setup into a composable, readable, compiler-checked API that speaks the language of the domain.

The Problem: Primitive-Heavy Setup

Before builders, tests tend to look like this:

[Test]
public void Loyalty_members_get_free_shipping_over_fifty_dollars()
{
    var customer = new Customer
    {
        Id = Guid.NewGuid(),
        Email = "test@example.com",
        LoyaltyTier = "Gold",
        JoinedAt = DateTime.UtcNow.AddYears(-2),
        Address = new Address { City = "San Francisco", State = "CA", Zip = "94102" },
    };
    var cart = new Cart
    {
        Items = new List<CartItem>
        {
            new CartItem { Sku = "BK-001", Quantity = 1, UnitPrice = 29.99m },
            new CartItem { Sku = "BK-002", Quantity = 1, UnitPrice = 24.99m },
        },
    };
    var order = new Order { Customer = customer, Cart = cart };

    var receipt = checkout.Process(order);

    Assert.That(receipt.ShippingCost, Is.EqualTo(0m));
}

Every field is noise. The reader has to scan twelve lines to find the one that matters (“Gold” loyalty tier), then hunt through the cart to confirm the subtotal is over fifty dollars. Multiply by a hundred tests and the suite becomes unreadable.

The Builder Pattern

A builder exposes only the dimensions a test cares about and hides everything else behind sensible defaults.

[Test]
public void Loyalty_members_get_free_shipping_over_fifty_dollars()
{
    var order = anOrder()
        .forCustomer(aLoyaltyMember())
        .containing(twoBooks())
        .shippedTo(california())
        .build();

    var receipt = checkout.Process(order);

    receipt.ShippingCost.Should().Be(Money.Zero);
}

The test now reads like a scenario. Every line contributes to the behavior being verified. The defaults for everything else — id, email, join date, payment method — live inside the builder, invisible to the test and consistent across the suite.

A Minimal Builder

public class OrderBuilder
{
    private Customer _customer = Customers.Standard();
    private IReadOnlyList<CartItem> _items = new[] { CartItems.Standard() };
    private Address _shipTo = Addresses.Standard();

    public static OrderBuilder anOrder() => new OrderBuilder();

    public OrderBuilder forCustomer(Customer c) { _customer = c; return this; }
    public OrderBuilder containing(params CartItem[] items) { _items = items; return this; }
    public OrderBuilder shippedTo(Address a) { _shipTo = a; return this; }

    public Order build() => new Order(_customer, _items, _shipTo);
}

Three rules keep builders honest:

  1. Start from a valid default. anOrder() must return an order that passes every invariant. Tests override only what they care about.
  2. Every setter returns the builder. Fluent chaining is what makes tests read like prose.
  3. build() (or implicit construction) produces a real domain object. Not a DTO, not a dictionary — the same type the production code uses.

Object Mothers

A builder exposes dimensions. An Object Mother exposes named instances — canonical examples of domain concepts that the whole suite agrees on.

public static class Customers
{
    public static Customer Standard() =>
        aCustomer().withEmail("test@example.com").build();

    public static Customer LoyaltyMember() =>
        aCustomer().withTier(LoyaltyTier.Gold).joinedYearsAgo(2).build();

    public static Customer NewSignup() =>
        aCustomer().joinedToday().withVerifiedEmail(false).build();

    public static Customer Suspended() =>
        aCustomer().withStatus(AccountStatus.Suspended).build();
}

When a test says aLoyaltyMember(), it’s pulling a named, shared definition. If the business changes what “loyalty member” means — joined for six months instead of a year, for example — that change happens in one place and every test that referenced it updates for free.

Object Mothers and Builders compose. Mothers produce the canonical instances; Builders let a single test tweak one dimension of a canonical instance when the scenario demands it:

var customer = aLoyaltyMember().withExpiredCard().build();

Scenario Factories

One level up, you have Scenario Factories — factories that produce worlds, not individual objects. A scenario factory preconfigures a coherent set of domain objects that belong together.

public static class CheckoutScenarios
{
    public static CheckoutWorld aCartReadyForCheckout() =>
        new CheckoutWorld(
            customer: Customers.Standard(),
            cart: Carts.WithOneBook(),
            payment: PaymentMethods.ValidCard(),
            address: Addresses.Standard()
        );
}

Tests then express scenarios in one line of preconfigured context, overriding only the dimensions under test:

var world = aCartReadyForCheckout()
    .withCustomer(aLoyaltyMember().inTheirFirstYear())
    .withPromotion(blackFridayDoubleDiscount());

Scenario factories are what turn a suite of isolated unit tests into a suite of behavioral specifications. Each test becomes a scenario name plus a list of deltas from a known-coherent starting world.

Layered World-Building

The mature pattern uses all three layers:

  • Domain types at the base — Money, SKU, Address, LoyaltyTier. Not strings, not decimals pretending to be prices.
  • Builders for composable construction with typed setters and fluent chaining.
  • Object Mothers for canonical named instances that encode shared domain understanding.
  • Scenario Factories for composed worlds that tests can start from and tweak.

Stack these and test code reads like the language the business uses. The test is the specification. The specification is executable. The specification evolves with the code because it’s made of the same types the code is made of.

Why This Matters for Agents

Agents compose by recombining the vocabulary they find in the codebase. Give them anOrder(), aLoyaltyMember(), twoBooks(), shippedTo(california()) and they will write new tests by composing those primitives in ways that stay inside the domain. The output reads like the rest of the suite because it uses the rest of the suite.

Give them primitive-heavy setup and they will generate more of the same. Every test stands alone. Nothing is reusable. The vocabulary doesn’t grow — it just accumulates.

Test Data Builders are pre-built ontology for the agent. Once they exist, agent-generated tests land clean, review faster, and merge without translation.

Rules of Thumb

  • One builder per aggregate root. If you need a builder for every class, your aggregates are too fine-grained.
  • Defaults must be valid. If anOrder().build() throws, the builder is broken.
  • Name setters in domain language. .forCustomer(...), not .setCustomerId(...).
  • Builders return domain objects, not builders. The thing the test receives should be usable as-is by production code.
  • Treat the builder API as a product surface. Review it. Refactor it. Write a README. Agents and new team members will learn your domain through it.