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.
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.
Related Articles
Rails GraphQL: Production Setup with graphql-ruby, Batch Loading, and Persisted Queries
Rails GraphQL with graphql-ruby done right — schema design, N+1 prevention with batch loading, persisted queries, and...
Rails Postgres EXPLAIN ANALYZE: Reading Query Plans to Fix Slow Rails Queries
Rails Postgres EXPLAIN ANALYZE reveals where queries spend their time. Read plans, spot Seq Scans, fix N+1s, and tune...
Streaming Claude Responses in Rails: SSE, Turbo Streams, and Real-Time AI Chat
Stream Claude responses in Rails with SSE and Turbo Streams. Token-by-token AI chat UI, backpressure, reconnects, and...