FactoryBot Advanced Strategies: Traits, Sequences, Callbacks, and Associations
FactoryBot is more than a replacement for fixtures. Advanced patterns—traits, transient attributes, build strategies, and lifecycle callbacks—let you create precisely the objects your tests need without duplicating setup logic or hitting the database unnecessarily.
Most Rails developers learn enough FactoryBot to define a basic factory and move on. That's fine until the test suite starts taking three minutes to run, factories start duplicating validation logic, or a test starts creating fifteen database records to set up one scenario.
This post covers the FactoryBot features that solve those problems: traits for reusable attribute sets, transient attributes for factory-level configuration, controlled association strategies, and callbacks used sparingly and correctly.
Traits: Reusable Attribute Bundles
Traits define a named set of attributes that can be mixed into any factory build:
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
sequence(:username) { |n| "user#{n}" }
password { "securepassword" }
role { :member }
confirmed_at { Time.current }
trait :admin do
role { :admin }
end
trait :unconfirmed do
confirmed_at { nil }
end
trait :suspended do
suspended_at { 1.day.ago }
suspended_reason { "Terms of service violation" }
end
trait :with_avatar do
after(:create) do |user|
user.avatar.attach(
io: File.open(Rails.root.join("spec/fixtures/avatar.jpg")),
filename: "avatar.jpg",
content_type: "image/jpeg"
)
end
end
end
endCombine traits in tests:
# Single trait
admin = create(:user, :admin)
# Multiple traits
suspended_admin = create(:user, :admin, :suspended)
# Trait plus override
create(:user, :admin, email: "specific@example.com")
# Unconfirmed user—good for testing confirmation flows
create(:user, :unconfirmed)Nested Traits
Traits can be defined inside other traits or factories, and they can nest shared traits:
factory :article do
title { "Default Title" }
body { "Body content" }
author
trait :published do
status { :published }
published_at { Time.current }
end
trait :draft do
status { :draft }
published_at { nil }
end
trait :featured do
published # includes the published trait
featured { true }
featured_at { Time.current }
end
trait :with_cover_image do
cover_image { Rack::Test::UploadedFile.new("spec/fixtures/cover.jpg", "image/jpeg") }
end
# Combines traits
factory :published_article, traits: [:published]
factory :featured_article, traits: [:featured, :with_cover_image]
endSequences
Sequences generate unique values across factory calls. They're essential for unique-constrained columns:
FactoryBot.define do
# Global sequences (reusable across factories)
sequence(:email) { |n| "user#{n}@example.com" }
sequence(:username) { |n| "user_#{n}" }
sequence(:slug) { |n| "post-slug-#{n}" }
factory :user do
email { generate(:email) }
username { generate(:username) }
end
factory :article do
slug { generate(:slug) }
# Inline sequence (scoped to this factory)
sequence(:title) { |n| "Article #{n}" }
end
endCustom Sequence Aliases
Sequences can produce non-integer values:
sequence(:token) { |n| "tok_#{SecureRandom.hex(8)}_#{n}" }
sequence(:uuid) { |_n| SecureRandom.uuid }
sequence(:phone) { |n| "555-%07d" % n }
sequence(:date) { |n| n.days.from_now }Transient Attributes
Transient attributes are factory-level configuration values that influence how a factory builds an object but are not set on the object itself. They're the mechanism for building flexible, parameterizable factories.
factory :user do
transient do
confirmed { true }
article_count { 0 }
with_team { false }
end
# Use transient attribute in regular attribute
confirmed_at { confirmed ? Time.current : nil }
after(:create) do |user, evaluator|
if evaluator.article_count > 0
create_list(:article, evaluator.article_count, author: user)
end
if evaluator.with_team
team = create(:team)
team.members << user
end
end
endUsage:
# Unconfirmed user
create(:user, confirmed: false)
# User with 5 articles
create(:user, article_count: 5)
# User with team and articles
create(:user, article_count: 3, with_team: true)Transient Attributes for Conditional Associations
One of the most useful patterns—controlling whether associations are created:
factory :order do
user
status { :pending }
total { 99.99 }
transient do
with_items { true }
item_count { 3 }
with_payment { false }
end
after(:create) do |order, evaluator|
if evaluator.with_items
create_list(:order_item, evaluator.item_count, order: order)
order.recalculate_total!
end
create(:payment, order: order) if evaluator.with_payment
end
end
# Tests that only care about order status (no items created)
order = create(:order, with_items: false)
# Full order for checkout tests
order = create(:order, item_count: 5, with_payment: true)Build Strategies
FactoryBot provides four build strategies. Using the right one for each test dramatically reduces database hits.
| Strategy | Method | Persists to DB? | Runs callbacks? |
|---|---|---|---|
| build | build(:model) |
No | No (after :build runs) |
| create | create(:model) |
Yes | Yes |
| build_stubbed | build_stubbed(:model) |
No | No |
| attributes_for | attributes_for(:model) |
No | No |
build — In-Memory Object
Use for unit tests on a model's methods and validations:
# No database hit—fast
user = build(:user, email: "test@example.com")
expect(user).to be_valid
user.email = "invalid"
expect(user).not_to be_valid
expect(user.errors[:email]).to include("is invalid")build_stubbed — Fastest Option
build_stubbed creates an object that looks persisted (has an id, returns persisted? as true) but never touches the database. Use for unit tests on objects that need to appear persisted:
user = build_stubbed(:user)
article = build_stubbed(:article, author: user)
# Has an id, acts persisted
expect(user.id).to be_present
expect(user).to be_persisted
# Perfect for policy tests, serializer tests, presenter tests
policy = ArticlePolicy.new(user, article)
expect(policy.edit?).to be(true)
# Serializer returns correct JSON without DB
json = ArticleSerializer.new(article).serializable_hash
expect(json[:data][:attributes][:title]).to eq(article.title)build_stubbed raises if you try to save or reload—good, because those are accidents in unit tests.
attributes_for — For Controller and Request Tests
Returns a plain hash of attributes, useful when submitting params:
RSpec.describe ArticlesController, type: :controller do
let(:valid_params) { attributes_for(:article) }
let(:invalid_params) { attributes_for(:article, title: nil) }
describe "POST #create" do
it "creates an article with valid params" do
expect {
post :create, params: { article: valid_params }
}.to change(Article, :count).by(1)
end
it "renders errors with invalid params" do
post :create, params: { article: invalid_params }
expect(response).to have_http_status(:unprocessable_entity)
end
end
endCallbacks: Use Sparingly
FactoryBot callbacks (after(:build), after(:create), before(:create)) run at specific points in object creation. They're powerful but easy to misuse.
Legitimate Uses
Creating required associations that must exist before or after the object:
factory :team do
name { "Engineering" }
# Requires at least one owner
after(:create) do |team|
create(:membership, team: team, role: :owner)
end
end
factory :invoice do
after(:create) do |invoice|
# Trigger the actual business logic
invoice.generate_line_items!
end
endFile Attachments
factory :document do
title { "Test Document" }
trait :with_file do
after(:create) do |document|
document.file.attach(
io: StringIO.new("PDF content"),
filename: "test.pdf",
content_type: "application/pdf"
)
end
end
endCommon Callback Mistakes
Avoid using callbacks to create associations that the test doesn't need—this creates unnecessary records and slows tests:
# BAD: every article gets tags whether the test needs them or not
after(:create) do |article|
create_list(:tag, 3, articles: [article])
end
# GOOD: opt-in via trait
trait :with_tags do
after(:create) do |article|
create_list(:tag, 3, articles: [article])
end
endAvoid callbacks that stub or mock—that belongs in the test:
# BAD: hiding behavior in factory
after(:build) do |user|
allow(user).to receive(:send_welcome_email) # This is a stub in a factory
endComplex Association Patterns
Belongs-To with Overrides
factory :comment do
body { "A thoughtful comment" }
user # creates a user by default
article # creates an article by default
# For testing comments on a specific article
trait :on_published_article do
association :article, factory: [:article, :published]
end
end
# In tests
comment = create(:comment) # user and article created automatically
comment = create(:comment, article: existing_article) # reuse existing article
comment = create(:comment, :on_published_article)Polymorphic Associations
factory :notification do
user
message { "You have a new notification" }
read { false }
trait :for_comment do
association :notifiable, factory: :comment
end
trait :for_article do
association :notifiable, factory: :article
end
end
# Must specify the notifiable type
notification = create(:notification, :for_comment)Circular Association Avoidance
When two factories reference each other, use create inside a block to break the cycle:
factory :organization do
name { "Acme Corp" }
# Don't set owner in after(:create)—that creates another org recursively
# Instead, provide a way to set it explicitly
transient do
with_owner { false }
end
after(:create) do |org, evaluator|
if evaluator.with_owner
owner = create(:user, organization: org)
org.update!(owner: owner)
end
end
endPerformance: Keeping Tests Fast
Profile your factory usage periodically:
# spec/support/factory_profiler.rb
if ENV["PROFILE_FACTORIES"]
FactoryBot.definition_file_paths.each { |path| require path }
RSpec.configure do |config|
config.after(:suite) do
stats = FactoryBot::Internal.factories.map do |factory|
[factory.name, factory.instance_variable_get(:@build_count) || 0]
end
stats.sort_by { |_, count| -count }.first(20).each do |name, count|
puts "#{count.to_s.rjust(6)} #{name}"
end
end
end
endPractical rules:
- Model unit tests: always
buildorbuild_stubbed - Request/integration tests:
createonly what's needed - Traits for opt-in associations: never create records the test doesn't assert on
- Sequence over
Faker: sequences are deterministic and faster than Faker's string generation
Key Takeaways
- Traits are composable building blocks—define them for every meaningful object state in your domain
- Transient attributes let tests configure factories without polluting the model interface
build_stubbedis the fastest strategy for unit tests on presenter, policy, and serializer objects- Use
attributes_forfor request/controller tests that submit params - Callbacks belong only in factories when the association or side effect is required for the object to be valid—otherwise use traits
- Audit your factory usage with
build_countprofiling when test runtime becomes a problem