FactoryBot Deep Dive: Test Data Factories for Ruby and Rails

FactoryBot Deep Dive: Test Data Factories for Ruby and Rails

FactoryBot (formerly FactoryGirl) is the de facto standard for test data in Ruby and Rails applications. It replaced fixture files for most teams because factories are composable, explicit, and stay synchronized with your models as schemas evolve. If you've inherited a Rails codebase with hundreds of YAML fixtures or inline User.create(...) calls scattered through test files, this guide shows you how to do it better.

Setup

Add to your Gemfile:

group :development, :test do
  gem "factory_bot_rails"
end

For non-Rails Ruby projects:

group :test do
  gem "factory_bot"
end

Configure RSpec to include FactoryBot helpers:

# spec/support/factory_bot.rb
RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

Place factory files under spec/factories/ — Rails autoloads them automatically with factory_bot_rails.

Basic Factory Definition

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    first_name { "Jane" }
    last_name  { "Doe" }
    email      { "jane.doe@example.com" }
    role       { "member" }
    active     { true }
  end
end

Use it in tests:

# Build (no DB)
user = build(:user)

# Create (persisted)
user = create(:user)

# Override attributes
admin = create(:user, role: "admin", email: "admin@example.com")

Sequences: Guaranteed Uniqueness

Static values cause ActiveRecord::RecordNotUnique errors the moment you create two objects with a unique constraint. Sequences solve this:

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    sequence(:username) { |n| "user_#{n}" }
    first_name { "Jane" }
    last_name  { "Doe" }
  end
end

Each call to create(:user) gets a unique email and username. Sequences are global by default — you can also define them outside factory blocks for reuse:

FactoryBot.define do
  sequence :email do |n|
    "user#{n}@example.com"
  end

  factory :user do
    email { generate(:email) }
  end

  factory :admin do
    email { generate(:email) }
  end
end

Dynamic Attributes with Blocks

Every attribute should be a block (not a static value) in FactoryBot. Blocks are evaluated at build time, so each object gets a fresh value:

# Bad — all users share the same created_at
factory :user do
  created_at Time.now
end

# Good — each user gets the current time at build
factory :user do
  created_at { Time.now }
end

This matters especially for Time/Date values and Faker calls.

Faker Integration

Install the faker gem and use it inside factory blocks:

require "faker"

FactoryBot.define do
  factory :user do
    first_name { Faker::Name.first_name }
    last_name  { Faker::Name.last_name }
    email      { Faker::Internet.unique.email }
    bio        { Faker::Lorem.paragraph(sentence_count: 3) }
    phone      { Faker::PhoneNumber.phone_number }
    website    { Faker::Internet.url }
    birth_date { Faker::Date.birthday(min_age: 18, max_age: 65) }
  end
end

Faker::Internet.unique.email uses Faker's uniqueness registry to avoid duplicates in the same test run. Reset it between examples if needed:

after(:each) { Faker::UniqueGenerator.clear }

Associations

When a factory needs to create associated records, use association:

FactoryBot.define do
  factory :post do
    title   { Faker::Lorem.sentence }
    body    { Faker::Lorem.paragraphs(number: 4).join("\n\n") }
    association :author, factory: :user
    published_at { 1.day.ago }
  end
end

FactoryBot automatically calls create(:user) when you create(:post). Override the association inline:

specific_author = create(:user, name: "Ada Lovelace")
post = create(:post, author: specific_author)

Override nested attributes with the association_attribute: value pattern:

post = create(:post, author: build(:user, email: "ada@lovelace.io"))

Avoiding Unnecessary DB Hits

When using build(:post), FactoryBot still calls create on associations by default. To prevent this, use build strategy on the association:

factory :post do
  author { association :user, strategy: :build }
end

Or use build_stubbed for the whole object graph when you don't need persistence at all:

post = build_stubbed(:post)
# post.id is a fake integer, no DB queries executed

Build Strategies

FactoryBot ships with four strategies:

Strategy Method Persists? Association
build build(:model) No Builds associations
create create(:model) Yes Creates associations
build_stubbed build_stubbed(:model) No Stubs associations
attributes_for attributes_for(:model) No Returns hash

Use attributes_for when testing controllers that accept parameter hashes:

it "creates a post" do
  post_params = attributes_for(:post)
  post "/posts", params: { post: post_params }
  expect(response).to have_http_status(:created)
end

Use build_stubbed for pure unit tests — it's the fastest strategy because it never touches the database, even for validations.

Traits: Composable Object States

Traits are named bundles of attribute overrides you can apply at build time:

FactoryBot.define do
  factory :user do
    first_name { Faker::Name.first_name }
    email      { Faker::Internet.unique.email }
    role       { "member" }
    active     { true }
    confirmed_at { nil }

    trait :admin do
      role { "admin" }
    end

    trait :confirmed do
      confirmed_at { 1.day.ago }
    end

    trait :inactive do
      active { false }
    end

    trait :with_avatar do
      avatar { Rack::Test::UploadedFile.new("spec/fixtures/avatar.jpg", "image/jpeg") }
    end
  end
end

Apply traits by name:

create(:user, :admin)
create(:user, :confirmed, :admin)
create(:user, :inactive)

Traits compose — combine as many as you need. They're far cleaner than subclasses or repeated override hashes in tests.

Transient Attributes

Sometimes you need a parameter that controls factory behavior without mapping to a model attribute. Use transient:

FactoryBot.define do
  factory :user do
    transient do
      admin_level { 1 }
    end

    first_name { "Jane" }
    role do
      admin_level >= 3 ? "superadmin" : "admin"
    end
  end
end

Transient attributes are accessible inside LazyAttribute-style blocks and post-build hooks but are not passed to the model constructor.

A common use: controlling how many associated records to create:

FactoryBot.define do
  factory :user do
    transient do
      post_count { 0 }
    end

    after(:create) do |user, context|
      create_list(:post, context.post_count, author: user)
    end
  end
end
user = create(:user, post_count: 5)
expect(user.posts.count).to eq(5)

Callbacks: after(:build), after(:create)

Callbacks run after a specific strategy completes:

FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }

    after(:create) do |user|
      create(:profile, user: user)
    end
  end
end

Common uses:

  • Creating required associations that can't be set before save
  • Triggering background jobs or state machine transitions
  • Setting up file attachments (ActiveStorage, Carrierwave)

Avoid callbacks that make side effects hard to reason about. If a callback runs on every create(:user) but most tests don't need the side effect, you're paying that cost unnecessarily — use a trait instead:

trait :with_profile do
  after(:create) do |user|
    create(:profile, user: user)
  end
end

Inheritance: Factory Hierarchies

Child factories inherit all attributes from their parent:

FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }
    role  { "member" }

    factory :admin_user do
      role { "admin" }
    end

    factory :moderator_user do
      role { "moderator" }
    end
  end
end
member = create(:user)
admin  = create(:admin_user)

Use inheritance sparingly. Traits are usually more flexible because they compose, while inheritance creates a rigid tree.

Aliases

When a model has multiple roles in your domain, aliases let you use expressive names:

FactoryBot.define do
  factory :user, aliases: [:author, :commenter, :reviewer] do
    email { Faker::Internet.unique.email }
  end
end

Now create(:author) and create(:reviewer) both use the :user factory:

factory :post do
  association :author
  association :reviewer
end

Avoiding Common Pitfalls

The create vs build Problem

Using create everywhere is the most common FactoryBot performance issue. Every create call hits the database. Run your test suite with --format documentation and look for suspiciously slow examples — they're usually creating dozens of objects when build or build_stubbed would suffice.

Rule of thumb:

  • Unit tests → build or build_stubbed
  • Integration/feature tests → create
  • Controller tests → attributes_for or build

Shared Context for Reusable Setup

When multiple tests need the same object graph, put it in a shared context:

# spec/support/shared_contexts/with_published_articles.rb
RSpec.shared_context "with published articles" do
  let!(:articles) { create_list(:post, 3, :published) }
end

# In specs
RSpec.describe ArticlesController do
  include_context "with published articles"
  # ...
end

Linting Your Factories

FactoryBot ships with a linter that builds every factory and checks for errors:

# spec/factories_spec.rb
RSpec.describe "FactoryBot factories" do
  it "are all valid" do
    FactoryBot.lint
  end
end

Run this in CI to catch factory/model drift before tests start mysteriously failing.

Real-World Example: E-commerce Order

Pulling it all together:

FactoryBot.define do
  factory :order do
    association :customer, factory: :user
    status { "pending" }
    currency { "USD" }
    total_cents { 0 }

    trait :with_items do
      transient do
        item_count { 2 }
      end

      after(:create) do |order, context|
        create_list(:line_item, context.item_count, order: order)
        order.recalculate_total!
      end
    end

    trait :paid do
      status { "paid" }
      paid_at { 1.hour.ago }
    end

    trait :refunded do
      status { "refunded" }
      refunded_at { Time.current }
    end

    trait :international do
      association :customer, factory: :user
      shipping_country { Faker::Address.country_code }
      requires_customs { true }
    end
  end
end

Tests read clearly:

create(:order, :with_items, item_count: 3, :paid)
create(:order, :international, :with_items)

Wrapping Up

FactoryBot earns its place in the Rails ecosystem by making test data explicit, composable, and close to your domain model. The key discipline: keep factories minimal by default, use traits for variations, use transient attributes for behavior flags, and default to build unless you need persistence.

A well-maintained factory file reads like documentation of your domain's object states — and that's exactly the right place to invest time in a growing Rails application.

Start now free