RUBY ON RAILS · 17 MIN READ ·

RSpec Rails: Factory Bot, VCR, and the Test Suite Patterns That Actually Scale

RSpec Rails done right — Factory Bot associations, VCR for external APIs, shared examples, and CI tricks that keep your Rails test suite under 3 minutes.

RSpec Rails: Factory Bot, VCR, and the Test Suite Patterns That Actually Scale

The first time I joined a codebase as a fractional CTO and found no test suite at all, I thought it was unusual. By the fifth time, I had a protocol. The first week is always the same: look at what tests exist, run them, measure how long they take, and see how many of them are actually catching bugs versus just asserting that a factory creates a record. Nine out of ten codebases fall into the same two failure modes: either almost no tests, or a bloated suite that takes twenty-five minutes on CI and flakes on every third run. Neither is useful. Both are expensive.

After nineteen years of Rails I have landed on an RSpec Rails setup that scales from a ten-developer startup to a forty-developer engineering org without the suite becoming a liability. This post is that setup, written down.

Why I Default to RSpec Rails Over Minitest

I want to get this out of the way because it is a genuine debate and I have an opinion. Minitest ships with Rails, runs faster out of the box, and is the right default for simple projects. I covered Rails Minitest with fixtures for exactly that reason. But for teams that need rich test infrastructure — shared examples across multiple model types, layered context blocks, custom matchers for domain assertions, and readable spec output on CI — RSpec is worth the overhead.

The overhead is real. RSpec Rails loads a DSL, an extension layer, and a formatter on every run. On a mature codebase with five thousand specs, that overhead disappears into noise. On a project with fifty specs it can double your run time. Use Minitest for new solo projects. Use RSpec for teams building anything that will need serious long-term test coverage.

Setting Up RSpec Rails the Right Way

The basics are straightforward. What matters is what you configure after the install.

# Gemfile
group :development, :test do
  gem "rspec-rails", "~> 7.0"
  gem "factory_bot_rails", "~> 6.4"
  gem "faker", "~> 3.4"
  gem "vcr", "~> 6.3"
  gem "webmock", "~> 3.23"
end

group :test do
  gem "shoulda-matchers", "~> 6.2"
  gem "simplecov", require: false
end
bundle install
bin/rails generate rspec:install

The generator creates .rspec, spec/spec_helper.rb, and spec/rails_helper.rb. The split is intentional. spec_helper.rb is for pure Ruby specs that do not need Rails. rails_helper.rb loads Rails and is for everything that touches the database, routes, or views.

The configuration I reach for in every project:

# spec/rails_helper.rb
require "spec_helper"
require "simplecov"
SimpleCov.start "rails"

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rspec/rails"
require "factory_bot_rails"
require "shoulda/matchers"

Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |f| require f }

RSpec.configure do |config|
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  config.include FactoryBot::Syntax::Methods
end

Shoulda::Matchers.configure do |config|
  config.integrate { |with| with.test_framework(:rspec).and.library(:rails) }
end

Two things worth calling out: use_transactional_fixtures = true wraps each spec in a database transaction and rolls it back at the end — this is the single biggest performance win in any RSpec Rails setup. And Dir[...] auto-loads everything in spec/support/, which is where your shared contexts, custom matchers, and VCR configuration live.

Factory Bot: The Patterns That Stop Blowing Up

Factory Bot is excellent. It is also the most common source of slow, fragile test suites in every Rails codebase I have inherited. The mistake everyone makes is building factories that create too much data by default.

# spec/factories/users.rb — DON'T do this
FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { Faker::Internet.email }
    association :subscription       # creates a Subscription record
    association :company            # creates a Company record
    after(:create) { |u| u.generate_api_token! }
  end
end

Every create(:user) fires three INSERT statements and runs a callback. If a spec needs five users and only cares about their email addresses, it has created fifteen records it never touches.

The correct approach: a minimal factory with explicit traits.

# spec/factories/users.rb — DO this
FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { Faker::Internet.unique.email }
    password { "password123" }
    role { :member }

    trait :admin do
      role { :admin }
    end

    trait :subscribed do
      after(:create) do |user|
        create(:subscription, user: user, plan: :pro)
      end
    end

    trait :with_company do
      association :company
    end

    trait :with_api_token do
      after(:create) { |u| u.generate_api_token! }
    end
  end
end

Now create(:user) is a single INSERT. When a spec needs a subscribed admin with an API token, it says create(:user, :admin, :subscribed, :with_api_token) and creates only what it needs. That discipline — traits for optional associations — is the difference between a suite that runs in two minutes and one that runs in twenty.

One more rule I enforce: never use create when build or build_stubbed will do. build_stubbed is the underused gem of the Factory Bot world. It constructs an object that pretends to be persisted — with a fake id, working association stubs, and no database interaction at all.

# Model spec testing only validations — zero database calls
let(:user) { build_stubbed(:user, email: nil) }

it { expect(user).not_to be_valid }
it { expect(user.errors[:email]).to include("can't be blank") }

At scale, stubbing everything you do not need to persist cuts your suite time by thirty to fifty percent. I measure it on every project after introducing it.

VCR and WebMock: Eliminating Live HTTP from Your RSpec Rails Suite

Every Rails app eventually integrates with external APIs — Stripe, Twilio, OpenAI, Postmark, whatever the week demands. The temptation is to stub HTTP calls manually with WebMock. That works until the external API changes a response field and your mocks silently diverge from reality. VCR solves this: it records real HTTP interactions on the first run and replays them from cassettes on subsequent runs.

# spec/support/vcr.rb
require "vcr"
require "webmock/rspec"

VCR.configure do |config|
  config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
  config.hook_into :webmock
  config.configure_rspec_metadata!

  config.filter_sensitive_data("<STRIPE_SECRET_KEY>") do
    ENV.fetch("STRIPE_SECRET_KEY", "sk_test_placeholder")
  end
  config.filter_sensitive_data("<OPENAI_API_KEY>") do
    ENV.fetch("OPENAI_API_KEY", "sk-placeholder")
  end

  config.default_cassette_options = {
    record: :new_episodes,
    match_requests_on: [:method, :host, :path]
  }
end

Then tag specs with the cassette you want:

# spec/services/stripe_billing_service_spec.rb
RSpec.describe StripeBillingService, :vcr do
  describe "#create_subscription" do
    let(:user) { create(:user, :with_stripe_customer) }
    let(:plan) { create(:plan, stripe_price_id: "price_H5ggYwtDq8") }

    it "creates a Stripe subscription and persists it locally" do
      subscription = described_class.create_subscription(user: user, plan: plan)

      expect(subscription).to be_persisted
      expect(subscription.stripe_subscription_id).to start_with("sub_")
      expect(user.reload.subscriptions.count).to eq(1)
    end
  end
end

The first time that spec runs, VCR hits the real Stripe sandbox and records the response. Every subsequent run, including CI, replays from the cassette. No network calls, no rate limits, deterministic output, and the recorded response stays close enough to the real API to catch response shape changes when you refresh the cassettes.

The filter_sensitive_data blocks are not optional — they scrub API keys from cassettes before they land in your repository. Always review cassette files before committing. API responses frequently contain customer IDs, tokens, and other data that should never be in source control.

For scenarios where you need to test specific error paths — timeouts, 5xx responses, authentication failures — raw WebMock stubs are cleaner:

RSpec.describe StripeBillingService do
  describe "#create_subscription error handling" do
    before do
      stub_request(:post, "https://api.stripe.com/v1/subscriptions")
        .to_return(status: 402, body: { error: { code: "card_declined" } }.to_json)
    end

    it "raises a PaymentDeclinedError on card decline" do
      expect do
        StripeBillingService.create_subscription(user: user, plan: plan)
      end.to raise_error(StripeBillingService::PaymentDeclinedError)
    end
  end
end

Use VCR for happy-path integration tests. Use manual WebMock stubs for error scenarios. They compose cleanly.

Shared Examples: Writing Spec Logic Once

Any codebase with more than a handful of models eventually has recurring patterns: every resource should be audited, every public endpoint should require authentication, every domain event should be timestamped. Shared examples let you define those assertions once and apply them everywhere.

# spec/support/shared_examples/auditable.rb
RSpec.shared_examples "auditable" do
  it "records an audit entry on create" do
    expect { subject }.to change(AuditLog, :count).by(1)
  end

  it "stores the performing user in the audit log" do
    subject
    expect(AuditLog.last.user).to eq(current_user)
  end
end

# spec/support/shared_examples/timestamped.rb
RSpec.shared_examples "timestamped resource" do
  it { is_expected.to respond_to(:created_at, :updated_at) }
  it { expect(subject.created_at).to be_a(ActiveSupport::TimeWithZone) }
end

Applied across your model specs:

RSpec.describe Order, type: :model do
  it_behaves_like "auditable"
  it_behaves_like "timestamped resource"
end

RSpec.describe Invoice, type: :model do
  it_behaves_like "auditable"
  it_behaves_like "timestamped resource"
end

Twenty lines of shared code, applied consistently across however many models you have. When the audit schema changes, you fix the shared example once. This is the pattern that makes an RSpec Rails suite feel like a leverage tool rather than a maintenance burden. I also reach for shared examples for feature flag guard specs — any endpoint gated behind a flag gets it_behaves_like "feature-flagged endpoint" to ensure the behavior is tested consistently.

RSpec Rails on CI: Getting Under Three Minutes

A suite that takes longer than three minutes on CI is a suite that developers skip running locally and wait for CI to catch their mistakes. That feedback loop is a productivity tax. The goal is three minutes wall-clock on CI, which means being deliberate about every second you add.

The wins, in order of impact:

Use --format progress on CI. The default documentation formatter adds real overhead from formatting each example name. The dots format is much faster and the output is still readable for diagnosing failures.

# .rspec
--require spec_helper
--format progress
--color

Parallelize with parallel_tests. The gem distributes specs across CPU cores automatically. Most CI runners give you four to eight cores. Four cores cuts your wall time by roughly 60 percent for a suite with even load distribution.

gem "parallel_tests", group: [:development, :test]
# .github/workflows/test.yml
- name: Set up test database shards
  run: bundle exec rake parallel:create parallel:migrate
  env:
    RAILS_ENV: test

- name: Run RSpec in parallel
  run: bundle exec parallel_rspec spec/ -- --format progress
  env:
    RAILS_ENV: test

The GitHub Actions CI/CD guide covers the full workflow configuration including caching bundler and the test database setup.

Profile your ten slowest specs. RSpec ships --profile built in. Run bundle exec rspec --profile 10 and you will always find two or three specs eating five to ten seconds each due to unnecessary database setup, external HTTP calls that VCR is not covering, or factories building associations nobody is using.

Tag and separate slow integration specs. Keep your fast unit and model specs running every push. Gate heavier integration tests behind a tag.

# spec/rails_helper.rb
RSpec.configure do |config|
  config.when_first_matching_example_defined(:slow) do
    config.filter_run_excluding :slow unless ENV["RUN_SLOW"] == "true"
  end
end

Tag any spec that spins up external services or runs a full request cycle as :slow. CI runs the fast suite in parallel and the slow suite in a separate job. Developers get fast feedback on every push.

FAQ

What is the difference between RSpec and Minitest in Rails?

Minitest is Rails’ built-in test framework — faster to set up, lower overhead, and the right default for simpler projects. RSpec is a DSL-based framework with a richer ecosystem: shared examples, custom matchers, context and describe nesting, and output that reads as specification prose. Both test the same code. RSpec scales better for larger teams that need to express complex behavioral specifications clearly and consistently.

How do I set up Factory Bot in RSpec Rails?

Add factory_bot_rails to your Gemfile, run bundle install, and include FactoryBot::Syntax::Methods in your RSpec.configure block. Create factory definitions in spec/factories/. Use traits for optional associations rather than embedding them in the base factory. Prefer build_stubbed in any spec that does not need real database persistence — it is significantly faster and eliminates a category of database-dependency bugs in your tests.

Should I use VCR or WebMock for external API tests in Rails?

Use both together. WebMock blocks all real outbound HTTP in the test environment, which prevents accidental live API calls. VCR sits on top of WebMock and replays recorded HTTP interactions from cassettes. Use VCR for integration tests against real external APIs. Use raw WebMock stubs when you need to test specific error conditions — timeouts, 4xx responses, network failures — that are difficult to record reliably with VCR.

How do I speed up a slow RSpec Rails test suite?

Run --profile 10 to find your ten slowest specs first. Switch to build_stubbed in any spec that does not need the database. Replace fat base factories with lean factories and explicit traits. Enable use_transactional_fixtures = true to roll back state between specs rather than truncating tables. Parallelize with parallel_tests. Then measure again — the profile output rarely lies about where your time is going.

Need help getting your RSpec Rails suite from painful to trusted? TTB Software has been building and auditing Rails test infrastructure for nineteen years. We know the patterns that keep a suite fast as the codebase grows.

#rspec-rails #rails-factory-bot #vcr-rspec-rails #rails-testing-patterns #rspec-shared-examples #rails-test-suite-performance #rspec-rails-2026

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software