Factory Bloat
FactoryBot factories that persist large graphs of associated records to the database for every test case, slowing the test suite by orders of magnitude because each create(:user) builds dozens of unrelated dependencies.
Before / After
# spec/factories/orders.rb
factory :order do
association :customer
association :shipping_address
association :billing_address
association :payment_method
after(:create) do |order|
create_list(:line_item, 5, order: order)
create(:invoice, order: order)
create(:shipment, order: order)
end
end
# In every spec
let(:order) { create(:order) }
# Inserts ~15 rows. 500 specs = 7,500 INSERTs.
# Suite takes 8 minutes for the DB alone. # For tests that only read attributes
let(:order) { build_stubbed(:order) }
# No DB hit, no callbacks, id is generated.
# For tests that hit one association
let(:order) { create(:order, :with_line_items) }
# Trait-based composition
factory :order do
association :customer
trait :with_line_items do
after(:build) do |order|
order.line_items << build_stubbed(:line_item)
end
end
trait :fully_loaded do
# only used when the spec actually needs it
end
end Why this hurts
Each create(:order) runs INSERTs for every associated record the factory declares, plus executes every ActiveRecord callback on every inserted record. With 15 records per create and 500 specs, the suite performs 7,500 INSERTs against the test database plus any cascading callback side effects. On PostgreSQL running in a Docker container shared with other CI jobs, each INSERT takes 2-5 ms including connection overhead, so the database alone contributes minutes of wall time before any Ruby business logic executes.
The cost is not just time. DatabaseCleaner (or Rails’ transactional fixtures) must undo every inserted row after the spec completes. Transactional fixtures roll back the wrapping transaction, which is fast, but DatabaseCleaner with truncation strategy (required when background threads or external processes commit data) truncates every table between every test, and the cleanup time grows with table count. Parallel test runners (parallel_tests, RSpec’s parallel_specs) multiply the database load proportionally, and PostgreSQL becomes the rate-limiting resource.
Shared let blocks at the top of describe groups propagate the factory cost to every example in the group. A factory used in context at the top of a describe with 20 examples pays the create cost 20 times even if only 3 examples actually read the object. RSpec’s let memoization runs per-example, not per-group, by design. let! is worse: it forces the factory to run before every example regardless of whether the example uses it.
Developers avoid writing tests when the suite is slow, which reduces coverage and increases bug escape rate. The flywheel is negative: slow tests cause fewer tests cause more bugs cause more slow tests to cover the bugs. build_stubbed generates an in-memory object with a valid id but no database row, eliminating the INSERT entirely. For tests that assert on object shape or method behavior (the majority of unit tests), build_stubbed is the correct tool.
See also: Upgrade FactoryBot Legacy Syntax.
Get Expert Help
Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.