Skip to content
Jhumur
All posts

7 min read

Keeping an RSpec Suite Fast and Trustworthy

Ruby on RailsTestingRSpec

Every Rails team eventually reaches the same point: the suite takes twenty minutes, three specs fail intermittently, and people start re-running the build instead of reading the failure. At that stage the tests have stopped being a safety net and become a tax.

The two problems reinforce each other. Slow suites get run less often, so bugs land. Flaky suites get distrusted, so real failures get ignored. Both are fixable, and mostly with the same set of habits.

Factories are where the time goes

The single biggest cost in most Rails suites is creating objects you did not need.

Build associations lazily

This factory creates a user, an account and a plan every time you touch a post — even in a spec that only checks a title validation:

# Eager: association is created with the parent, always
FactoryBot.define do
  factory :post do
    title { "A title" }
    author { create(:user) }
  end
end

Using the association declaration defers it, so build(:post) creates nothing in the database:

FactoryBot.define do
  factory :post do
    title { "A title" }
    association :author, factory: :user
  end
end

Use the cheapest strategy that works

create writes to the database. build does not. build_stubbed does not even instantiate the associations.

# Three INSERTs
let(:post) { create(:post) }
 
# No INSERT — enough for validations and plain instance methods
let(:post) { build(:post) }
 
# No INSERT, no association objects, has an id — enough for view and presenter specs
let(:post) { build_stubbed(:post) }

The rule of thumb: use create only when the code under test issues a query. Validations, plain methods, serializers and presenters almost never need it.

Keep sequences out of Faker where you need uniqueness

Faker does not guarantee unique values, and a random collision produces a failure that reproduces once every few hundred runs:

# Will eventually collide against a unique index
email { Faker::Internet.email }
 
# Deterministic and unique
sequence(:email) { |n| "user#{n}@example.com" }

If you want realistic data, combine them:

sequence(:email) { |n| "#{Faker::Internet.username}#{n}@example.com" }

Find the expensive factories

factory_bot can report what it built. Add this and run your slowest file:

# spec/support/factory_profiling.rb
if ENV["PROFILE_FACTORIES"]
  counts = Hash.new(0)
 
  ActiveSupport::Notifications.subscribe("factory_bot.run_factory") do |_, _, _, _, payload|
    counts["#{payload[:factory].name} (#{payload[:strategy]})"] += 1
  end
 
  at_exit do
    puts "\nTop factories:"
    counts.sort_by { |_, v| -v }.first(15).each { |name, n| puts "  #{n.to_s.rjust(5)}  #{name}" }
  end
end

A count far larger than the number of examples in the file is the signal to look for an eager association or a let! that should have been a let.

Pick the right level of test

A request spec that renders a full page is roughly two orders of magnitude more expensive than a model spec. Both are useful; the mistake is testing branching logic through the most expensive door available.

# Expensive: boots the router, controller, views, and asset lookups
# to test a pricing rule
RSpec.describe "GET /invoices/:id", type: :request do
  it "applies the annual discount" do
    invoice = create(:invoice, :annual, subtotal: 100)
    get invoice_path(invoice)
    expect(response.body).to include("90.00")
  end
end
# Cheap: tests the rule directly, and states it more clearly
RSpec.describe Invoice do
  describe "#total" do
    it "applies the annual discount" do
      invoice = build(:invoice, :annual, subtotal: 100)
      expect(invoice.total).to eq(90)
    end
  end
end

Keep one request spec per endpoint to prove it is wired up — the route resolves, authorisation applies, the happy path renders. Push the permutations down to unit specs. A useful shape is many model and service specs, some request specs, and a small number of system specs covering only the critical user journeys.

System specs deserve particular restraint. They are the slowest and by far the flakiest thing in the suite, and every additional one multiplies both problems.

Where flakiness comes from

Flaky tests almost always trace back to state that leaks between examples, or to time.

Leaking global state

Anything you mutate outside the database survives the transaction rollback:

# Leaks: the next example sees the changed configuration
it "disables signups when locked" do
  Rails.application.config.signups_enabled = false
  expect(SignupPolicy.new.allowed?).to be(false)
end
# Restored automatically after the example
it "disables signups when locked" do
  allow(Rails.application.config).to receive(:signups_enabled).and_return(false)
  expect(SignupPolicy.new.allowed?).to be(false)
end

The same applies to class-level memoisation, ENV, Time.zone, and constants. For constants, use stub_const:

stub_const("Importer::BATCH_SIZE", 2)

Order dependence

Run in a random order, and make the seed reproducible:

# spec/spec_helper.rb
RSpec.configure do |config|
  config.order = :random
  Kernel.srand config.seed
end

When a build fails, rspec --seed 12345 reproduces it exactly. If a spec only passes in a fixed order, it depends on state another spec created — that is a real bug in the test, and often a real bug in the code.

Time

Tests that construct dates relative to Time.now fail at month boundaries, during DST changes, or when the build runs at 23:59.

# Fails on the 31st of a month, and around DST
expect(subscription.renews_on).to eq(1.month.from_now.to_date)

Freeze time explicitly:

# spec/rails_helper.rb
RSpec.configure do |config|
  config.include ActiveSupport::Testing::TimeHelpers
end
it "renews a month after purchase" do
  travel_to Time.zone.parse("2024-03-15 10:00:00") do
    subscription = create(:subscription)
    expect(subscription.renews_on).to eq(Date.new(2024, 4, 15))
  end
end

Set a single timezone for the whole suite too, so a developer's local machine and CI agree:

config.before(:suite) { Time.zone = "UTC" }

Ordering assumptions

SELECT without ORDER BY returns rows in whatever order PostgreSQL finds convenient. It is usually insertion order, until a row is updated and moves.

# Passes locally, fails eventually
expect(Post.all.map(&:title)).to eq(["First", "Second"])
# Order-independent, or explicitly ordered
expect(Post.all.map(&:title)).to contain_exactly("First", "Second")
expect(Post.order(:created_at).map(&:title)).to eq(["First", "Second"])

System specs and waiting

Never sleep. Capybara's finders already retry until the timeout, and sleep either wastes time or is too short under load.

# Flaky and slow
click_button "Save"
sleep 2
expect(page).to have_content("Saved")
# Waits exactly as long as needed
click_button "Save"
expect(page).to have_content("Saved")

The distinction that catches people out: have_content waits, but page.text.include? does not. Any assertion that reads state into Ruby first loses the retry behaviour.

Configuration that pays for itself

# spec/spec_helper.rb
RSpec.configure do |config|
  config.disable_monkey_patching!
 
  config.expect_with :rspec do |expectations|
    expectations.syntax = :expect
  end
 
  config.mock_with :rspec do |mocks|
    # Fail if you stub a method the real object does not have.
    mocks.verify_partial_doubles = true
  end
 
  config.order = :random
  Kernel.srand config.seed
 
  # Surface the slowest examples so the list stays honest.
  config.profile_examples = 10 if ENV["CI"]
 
  # Re-run only what failed last time.
  config.example_status_persistence_file_path = "tmp/rspec_examples.txt"
end

verify_partial_doubles is the highest-value line there. Without it, a stub for a method you later renamed keeps passing forever, and the spec silently tests nothing.

Running less, and in parallel

Two mechanical wins once the suite is clean.

parallel_tests shards across cores, with one database per worker:

bundle exec rake parallel:create parallel:load_schema
bundle exec parallel_rspec spec/

And in CI, run the fast specs first so a broken build fails in thirty seconds instead of twenty minutes:

# .github/workflows/ci.yml
- name: Unit specs
  run: bundle exec rspec spec/models spec/services --format progress
 
- name: Request specs
  run: bundle exec rspec spec/requests --format progress
 
- name: System specs
  run: bundle exec rspec spec/system --format progress

What good looks like

  • Unit specs use build or build_stubbed; create appears only where a query runs.
  • Random order is on, and the seed is printed on every run.
  • Time is frozen wherever a date is asserted.
  • verify_partial_doubles is enabled.
  • No sleep anywhere in the suite.
  • The slowest ten examples are visible in CI output, and someone looks at them.

None of this is clever. It is mostly the discipline of not creating rows you do not need and not depending on state you did not set. Get those two right and a suite stays fast for years.