Builders, Factories & DB Seeding
Test Data Builder — Full Pattern
A builder creates a single domain object with configurable defaults. The same builder is used wherever that object is needed.
import uuid
from dataclasses import dataclass, field
from typing import Self
@dataclass
class ProductBuilder:
sku: str = field(default_factory=lambda: f"SKU-{uuid.uuid4().hex[:6].upper()}")
name: str = field(default_factory=lambda: f"Product {uuid.uuid4().hex[:4]}")
price: float = 9.99
stock: int = 100
category: str = "GENERAL"
active: bool = True
def out_of_stock(self) -> Self:
self.stock = 0
return self
def in_category(self, category: str) -> Self:
self.category = category
return self
def priced_at(self, price: float) -> Self:
self.price = price
return self
def inactive(self) -> Self:
self.active = False
return self
def build(self) -> dict:
return {
"sku": self.sku,
"name": self.name,
"price": self.price,
"stock": self.stock,
"category": self.category,
"active": self.active,
}
Usage:
# Default in-stock product
product = ProductBuilder().build()
# Specific scenario
out_of_stock = ProductBuilder().out_of_stock().in_category("ELECTRONICS").build()
Nested Builders
Compose builders for objects with nested structures.
@dataclass
class OrderBuilder:
user_id: str = field(default_factory=lambda: str(uuid.uuid4()))
items: list = field(default_factory=list)
shipping_address: dict = field(
default_factory=lambda: AddressBuilder().build()
)
status: str = "PENDING"
def with_item(self, sku: str, qty: int = 1) -> Self:
self.items.append({"sku": sku, "quantity": qty})
return self
def for_user(self, user_id: str) -> Self:
self.user_id = user_id
return self
def build(self) -> dict:
return {
"user_id": self.user_id,
"items": self.items,
"shipping_address": self.shipping_address,
"status": self.status,
}
Resource Factory
A factory creates and tracks resources for cleanup. Useful when tests create multiple resources and need guaranteed teardown.
class UserFactory:
def __init__(self, client) -> None:
self._client = client
self._created: list[str] = []
def create(self, **overrides) -> dict:
data = {**UserBuilder().build(), **overrides}
user = self._client.users.create(data)
self._created.append(user["id"])
return user
def create_admin(self) -> dict:
return self.create(role="ADMIN")
def cleanup(self) -> None:
for user_id in self._created:
self._client.users.delete(user_id)
self._created.clear()
@pytest.fixture
def user_factory(api_client) -> UserFactory:
factory = UserFactory(api_client)
yield factory
factory.cleanup()
def test_admin_can_see_all_users(api_client, user_factory):
admin = user_factory.create_admin()
users = [user_factory.create() for _ in range(3)]
response = api_client.get("/admin/users", headers=auth(admin))
assert len(response.json()) >= 3
# factory.cleanup() runs automatically after test
DB Seeding
For tests that require pre-existing data in the database.
Direct SQL Seed
import pytest
import psycopg2
@pytest.fixture(scope="session")
def seeded_db(db_connection):
cursor = db_connection.cursor()
cursor.execute("""
INSERT INTO product_categories (name, slug)
VALUES ('Electronics', 'electronics'), ('Books', 'books')
ON CONFLICT (slug) DO NOTHING
""")
db_connection.commit()
yield
cursor.execute("DELETE FROM product_categories WHERE slug IN ('electronics', 'books')")
db_connection.commit()
Migration-Based Seed
Use Alembic data migrations or a dedicated seed script:
# data/seed.py
def seed_reference_data(session) -> None:
categories = [
Category(name="Electronics", slug="electronics"),
Category(name="Books", slug="books"),
]
for cat in categories:
session.merge(cat)
session.commit()
Run once at session scope in CI, not per-test.
Builder Checklist
| Property | Required |
|---|---|
| Unique defaults (UUID-based) | Yes |
Returns self from mutator methods |
Yes |
build() returns plain dict or dataclass |
Yes |
| No API calls inside builder | Yes |
| Raises error on missing required fields | Recommended |