Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 041 / 100

FactoryBot for test data: a hands-on guide for engineers

Readable test setup.

Expona Admin7 min read

Introduction

Test data setup can easily become a bottleneck in your test suite—factories with inconsistent state, brittle associations, and verbose boilerplate that obscures what you're actually testing. FactoryBot for test data is the tool that brings structure to this chaos, but only when you understand how to wield it effectively. In this guide, we'll walk through the practical patterns that senior engineers use to keep test setup readable, maintainable, and fast.

Factories with sensible defaults

A well-designed factory is the foundation of maintainable tests. Instead of scattering test data creation across your specs, a factory centralizes that logic and makes your tests read like intent rather than mechanics.

Consider a basic user factory:

ruby
FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }
    password { "SecurePass123!" }
    first_name { "John" }
    last_name { "Doe" }
    status { "active" }
    created_at { Time.current }
  end
end

This gives you sensible defaults that match real-world usage. When you call create(:user), you get a valid, complete user record without specifying every field. The Faker gem ensures uniqueness where it matters—critical for email addresses that often have uniqueness constraints.

The real power emerges when defaults match your business logic. If users are almost always active when created in production, make status default to "active". If your application assigns users to a default organization, bake that into the factory. This means your tests focus on the exception, not the rule.

In a more complex scenario—say, an e-commerce platform—your order factory might look like this:

ruby
FactoryBot.define do
  factory :order do
    user
    order_number { SecureRandom.hex(6).upcase }
    subtotal { 99.99 }
    tax_amount { 9.99 }
    total { subtotal + tax_amount }
    status { "pending" }
    currency { "USD" }
    shipping_address { association :address, address_type: "shipping" }
    
    association :user
  end
end

Notice that subtotal, tax_amount, and total have defaults that reflect real commerce. When a test needs to verify tax calculation behavior, it doesn't waste mental cycles on setup—the numbers are already sensible.

Traits for variations

Sensible defaults cover the happy path, but real applications need to test edge cases. Instead of creating multiple factories, traits let you express variations clearly and concisely.

Traits are named sets of attributes that you apply to a factory. They're powerful because they document the variation right in the test, making intent explicit.

ruby
FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }
    password { "SecurePass123!" }
    first_name { "Jane" }
    last_name { "Smith" }
    status { "active" }
    
    trait :inactive do
      status { "inactive" }
    end
    
    trait :admin do
      role { "admin" }
      permissions { ["read", "write", "delete"] }
    end
    
    trait :unverified do
      email_verified { false }
      email_verified_at { nil }
    end
    
    trait :with_profile do
      association :profile
    end
  end
end

Now your tests read naturally:

ruby
it "prevents inactive users from logging in" do
  user = create(:user, :inactive)
  expect { user.authenticate(password) }.to raise_error(AuthenticationError)
end

it "allows admins to delete users" do
  admin = create(:user, :admin)
  regular_user = create(:user)
  expect { admin.delete_user(regular_user) }.not_to raise_error
end

it "sends verification email to unverified users" do
  user = create(:user, :unverified)
  expect(EmailJob).to receive(:perform_later).with(user.email, "verify")
  user.send_verification_email
end

Each test describes its setup in plain language. You can combine traits too:

ruby
admin_user = create(:user, :admin, :inactive)  # Admin who is inactive

Traits prevent the factory explosion that happens when you create :admin_user, :inactive_user, :unverified_user, :admin_inactive_user, and so on. The combinatorial mess stays out of your factory definitions.

create vs build: when to persist

FactoryBot gives you two primary methods: build and create. Understanding when to use each one is essential for test speed and clarity.

build instantiates an object in memory without persisting it to the database. create saves it. This distinction matters more than it seems:

  • Use build when you're testing business logic that doesn't require persistence. Validations, calculations, associations already loaded in memory—these don't need the database.
  • Use create when your test exercises database queries, associations that need to be fetched, or any code that queries the database.

A practical example:

ruby
describe Order do
  describe "#calculate_discount" do
    it "applies 10% discount for orders over $500" do
      # build is enough; we're not querying the database
      order = build(:order, subtotal: 600)
      expect(order.calculate_discount).to eq(60)
    end
  end
  
  describe ".pending" do
    it "returns only pending orders" do
      # create is necessary; we're querying the database
      pending_order = create(:order, status: "pending")
      completed_order = create(:order, status: "completed")
      
      expect(Order.pending).to include(pending_order)
      expect(Order.pending).not_to include(completed_order)
    end
  end
end

In the first test, build is faster because it skips database I/O. In the second, create is necessary—without it, your scope query won't have data to filter.

Over a test suite with thousands of specs, the difference between unnecessary create calls and efficient build calls can mean the difference between a 30-second test run and a 5-minute slog. Choose deliberately.

Avoid deep association chains

Associations are seductive. You can write:

ruby
FactoryBot.define do
  factory :order_item do
    association :order, factory: :order do
      association :user, factory: :user do
        association :organization, factory: :organization
      end
    end
  end
end

But this creates a problem. Every time you create an order item, you're creating an entire tree of objects. Your tests become slow, and you lose control over what data is actually relevant to your test.

Instead, build associations at the level you need them:

ruby
FactoryBot.define do
  factory :order_item do
    association :order
    quantity { 2 }
    unit_price { 29.99 }
    product_id { 1 }
  end
  
  factory :order do
    association :user
    order_number { SecureRandom.hex(6).upcase }
    total { 59.98 }
  end
  
  factory :user do
    email { Faker::Internet.unique.email }
    password { "SecurePass123!" }
    status { "active" }
  end
end

When you need an order item with a specific user or organization, you create it explicitly:

ruby
it "applies organization-level discounts" do
  org = create(:organization, discount_percentage: 15)
  user = create(:user, organization: org)
  order = create(:order, user: user)
  item = create(:order_item, order: order)
  
  expect(item.calculate_discount).to eq(item.unit_price * 0.15)
end

This is more verbose than a deep chain, but it's also more explicit. You control which objects exist and why. Tests become easier to debug because you know exactly what your setup creates.

As a rule, if you're nesting more than one or two associations in a factory definition, you're building a footgun. Keep factories shallow and let tests assemble the relationships they need.

Common mistakes

Using random data everywhere. Some teams default to Faker::Name.name for every string field. This makes tests brittle—if you ever need to assert on the name value, the test fails randomly. Use Faker for uniqueness constraints (email, phone) and static values for everything else.

Not using traits when you should. Teams often end up with dozens of closely related factories instead of one factory with clear traits. Review your factory files regularly; if you're repeating the same overrides in multiple specs, extract a trait.

Persisting when you don't need to. Every unnecessary create hits the database. In a 10,000-spec suite, this adds up to minutes of wasted time. Prefer build unless your test absolutely requires database records.

Hiding test intent behind factory magic. Factories should make tests more readable, not less. If a test requires unusual setup, consider inline instantiation or a custom factory. Better to be explicit than clever.

Creating too much data in factories. A factory should create the minimum viable object. If your test needs five related users with specific attributes, create them in the test, not in the factory. Factories are for the common case.

Key takeaways

  • Use sensible defaults that match your production behavior—tests should focus on the exception, not the setup.
  • Traits express variations clearly and prevent factory explosion; they make test intent explicit.
  • Prefer build for in-memory logic tests and create when database queries are involved; the distinction directly impacts test speed.
  • Keep associations shallow and explicit; let tests assemble relationships they need rather than nesting factories deeply.
  • Review factories regularly for hidden complexity; readable test setup is a skill that compounds across a team.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles