FactoryBot Advanced Strategies: Traits, Sequences, Callbacks, and Associations

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
end

Combine 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]
end

Sequences

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
end

Custom 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
end

Usage:

# 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
end

Callbacks: 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
end

File 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
end

Common 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
end

Avoid 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
end

Complex 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
end

Performance: 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
end

Practical rules:

  • Model unit tests: always build or build_stubbed
  • Request/integration tests: create only 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_stubbed is the fastest strategy for unit tests on presenter, policy, and serializer objects
  • Use attributes_for for 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_count profiling when test runtime becomes a problem

Read more

Start now free