Fixtures vs Factories: Which Test Data Strategy Should You Use?
The fixtures-versus-factories debate has been running in test communities for over a decade. It's not really a debate — both approaches have distinct strengths, and the teams that struggle most with test data are the ones who picked one dogmatically without understanding the tradeoffs. This guide breaks down the real differences, when each approach wins, and how hybrid strategies extract the best of both.
What Are Fixtures?
Fixtures are static snapshots of data loaded into the database before your tests run. In Rails, they're YAML files:
# spec/fixtures/users.yml
jane:
id: 1
email: jane@example.com
role: admin
created_at: 2024-01-01 00:00:00
updated_at: 2024-01-01 00:00:00
bob:
id: 2
email: bob@example.com
role: member
created_at: 2024-01-01 00:00:00
updated_at: 2024-01-01 00:00:00In Django, fixtures are JSON or YAML files loaded with manage.py loaddata:
[
{
"model": "myapp.user",
"pk": 1,
"fields": {
"email": "jane@example.com",
"role": "admin"
}
}
]In pytest, conftest fixtures are Python functions:
@pytest.fixture
def user(db):
return User.objects.create(email="jane@example.com", role="admin")What Are Factories?
Factories are code that generates test data on demand. Each test gets fresh objects with predictable defaults, and you override only what's relevant:
# factory_boy (Python)
user = UserFactory.create(role="admin")
# FactoryBot (Ruby)
user = create(:user, :admin)
# Rosie (JavaScript)
const user = Factory.build('user', { role: 'admin' })The key difference: fixtures are data you declare once and share. Factories generate data per-test with fresh state.
Performance: The Numbers That Actually Matter
Fixtures load once (or once per test class with transactional cleanup), making individual tests very fast. Factories create records during each test, which adds overhead proportional to the number of records created.
Rough benchmarks on a typical Rails app with PostgreSQL:
| Scenario | Fixtures | Factories |
|---|---|---|
| Suite startup | 200–500ms (load) | ~0ms |
| Single test (1 object) | ~0ms | 3–15ms |
| Single test (10 objects) | ~0ms | 30–150ms |
| 500-test suite (avg 5 objects/test) | ~1–2s total | ~10–30s total |
These numbers shift significantly with:
build_stubbed/build: Factory tests with no DB access match fixture speed- Database transactions: Both approaches benefit from rolling back transactions after each test rather than truncating tables
- Association depth: Factories that create deep object graphs (user → org → subscription → billing) multiply the cost
For suites with thousands of tests, the factory overhead becomes significant. A 5,000-test suite creating 10 records per test at 10ms each is 8+ minutes of just DB writes.
Maintainability: Where Factories Win
Fixtures break silently. Consider what happens when you add a non-nullable column to a table with a database default — your fixtures don't include the column, but they still work because the DB fills it in. Then you remove the database default. Every test that uses that fixture now fails, not with a meaningful error, but with a null constraint violation. Finding all affected fixtures across a large YAML corpus is painful.
Factories are code, so they're subject to the same refactoring tools as the rest of your codebase. Add a required field to UserFactory and every test that uses :user gets it immediately. Rename a column and your IDE can find all factory references.
The Dependency Problem
Fixtures have implicit dependencies. Consider:
# posts.yml
first_post:
title: "Hello World"
author_id: 1 # Depends on users.yml having id: 1
category_id: 3 # Depends on categories.yml having id: 3This coupling means you can't load posts.yml without loading users.yml and categories.yml first — and in the right order. As the fixture corpus grows, this dependency graph becomes fragile and hard to reason about.
Factories express dependencies explicitly through associations:
factory :post do
association :author, factory: :user
association :category
endThe dependency is visible, and the factory handles ordering automatically.
Test Isolation: The Core Tradeoff
Fixtures are shared state. Every test in a fixture-using suite sees the same jane user. This is fast but creates subtle coupling:
# Test A modifies jane's email
# Test B checks jane's email — but now it's different
# Order-dependent test failures are the resultRails wraps each test in a transaction and rolls back after, which prevents most cross-test pollution. But this only works when all database access happens in the same connection. Background jobs, multi-threaded tests, and tests that call system commands bypass the transaction and mutate fixture state.
Factories give each test a private object graph. Tests can mutate their objects without affecting others. This isolation comes at the cost of setup time — and the mental overhead of setting up the exact data each test needs.
When Fixtures Win
1. Read-Heavy Test Suites
If your tests mostly read and don't modify shared objects, fixture performance is hard to beat. A suite that tests search, filtering, and reporting against a stable dataset is a natural fit.
2. Domain-Specific Named States
Some objects are canonical in your domain: the "free" tier, the "admin" role, the "default" currency. Fixtures let you give these objects meaningful names (users(:admin), tiers(:free)) that read clearly in tests.
3. Integration Tests with Complex Joins
A fixture set that models a realistic slice of production data (users, orgs, subscriptions, billing records, all with correct FK relationships) is genuinely difficult to replicate with factories in each test. One time setup beats N factories.
4. Seed Data That Is Part of the Domain
If certain records must exist for your application to function (roles, permissions, feature flags, system users), fixtures model this naturally. Factories that create these on demand can cause duplication issues.
When Factories Win
1. Unit and Integration Tests with Specific Object States
When a test cares about exactly one property — "user with expired subscription", "order with mismatched billing address", "post with XSS in title" — a factory lets you express that cleanly:
order = OrderFactory.create(
billing_address__country="US",
shipping_address__country="DE",
status="pending"
)No need to add a new fixture variant every time you need a slightly different state.
2. TDD Workflows
When driving development with tests, you often need new object shapes before the model is stable. Factories are easier to update incrementally than fixture files.
3. Test Suites with Many Unique States
If your test suite tests many permutations of object state (a payment system with a dozen valid/invalid card states, an auth system with many permission combinations), factories scale better than an ever-growing fixture file.
4. Multi-Tenant Applications
In a multi-tenant app, tests need to create isolated tenant contexts. Creating fresh tenants per test with factories avoids the coordination problem of shared fixture tenants.
Hybrid Approaches
The most pragmatic teams use both.
Pattern 1: Fixtures for Reference Data, Factories for Test-Specific Data
Reference data (roles, categories, countries, feature flags) lives in fixtures. It's loaded once, rarely changes, and is genuinely shared across all tests. Test-specific users, orders, and posts are created with factories.
# Fixture for shared reference data
RSpec.describe OrderService do
fixtures :currencies, :shipping_zones
it "applies discount for premium members" do
user = create(:user, :premium)
order = create(:order, user: user, currency: currencies(:usd))
# ...
end
endPattern 2: Factory Defaults Seeded from Fixtures
Factories reference fixture IDs for mandatory foreign keys:
factory :post do
category_id { Category.find_by!(name: "General").id }
endThis is fragile — it creates an implicit fixture dependency in factory code. Better: use get_or_create patterns or ensure the factory creates its own category when needed.
Pattern 3: Scenario-Based Fixture Sets
Instead of one giant fixture set, maintain small fixture sets for specific scenarios. A payment_scenarios fixture set for billing tests, a permission_scenarios set for auth tests. Each is loaded only by the tests that need it.
In pytest:
@pytest.fixture(scope="module")
def payment_scenario(django_db_setup, django_db_blocker):
with django_db_blocker.unblock():
call_command("loaddata", "payment_scenarios.json")Tool Landscape
| Tool | Language | Strategy | Notes |
|---|---|---|---|
| Rails fixtures | Ruby | Fixtures | Built-in, YAML-based |
| FactoryBot | Ruby | Factories | De facto Rails standard |
| factory_boy | Python | Factories | Best Python option |
| pytest fixtures | Python | Fixtures | Built-in, function-based |
| Django fixtures | Python | Fixtures | loaddata, JSON/YAML |
| Fishery | TypeScript | Factories | Clean TS factory lib |
| Rosie | JavaScript | Factories | Lightweight JS factories |
| Fabrication | Ruby | Factories | FactoryBot alternative |
The Real Decision Framework
Ask these questions:
- How stable is your schema? Rapidly changing schemas favor factories — updating a factory file is one change. Updating all related fixtures can be many.
- How many tests hit the database? Fewer DB tests → fixture performance advantage shrinks. Many DB tests → fixture startup cost is amortized.
- How much state variance do your tests need? High variance (many distinct object states) → factories. Low variance (same objects queried differently) → fixtures.
- Do you have canonical domain objects? Yes → fixtures for those objects. Everything else → factories.
- Are you testing across multiple connections or with background workers? Yes → fixtures with transactions become risky. Factories per test are safer.
Conclusion
Neither fixtures nor factories are universally better. Experienced teams use fixtures for stable reference data and named canonical objects, and factories for test-specific object creation. The mistake is treating this as an ideological choice rather than a practical one.
If you're starting fresh: begin with factories. They're easier to learn incrementally and scale better as test suites grow. Introduce fixtures when you find yourself creating the same shared objects in dozens of tests with no variation.
If you're maintaining a legacy fixture corpus: don't migrate wholesale. Identify the tests with the most fixture coupling and convert them to factories first. Let the rest run until maintenance cost justifies the switch.