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"
endFor non-Rails Ruby projects:
group :test do
gem "factory_bot"
endConfigure RSpec to include FactoryBot helpers:
# spec/support/factory_bot.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
endPlace 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
endUse 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
endEach 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
endDynamic 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 }
endThis 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
endFaker::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
endFactoryBot 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 }
endOr 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 executedBuild 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)
endUse 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
endApply 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
endTransient 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
enduser = 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
endCommon 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
endInheritance: 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
endmember = 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
endNow create(:author) and create(:reviewer) both use the :user factory:
factory :post do
association :author
association :reviewer
endAvoiding 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 →
buildorbuild_stubbed - Integration/feature tests →
create - Controller tests →
attributes_fororbuild
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"
# ...
endLinting 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
endRun 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
endTests 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.