RSpec with Rails: Request Specs, Model Specs, System Specs, and More
Testing a Rails application with RSpec involves more than just the base rspec gem. You need rspec-rails for Rails-specific example groups, factory_bot_rails for test data, capybara for browser interaction, and database_cleaner-active_record to keep your database clean between tests. This post covers how to wire all of it together and how to write each type of spec.
Setup
Gemfile
group :development, :test do
gem 'rspec-rails', '~> 6.1'
gem 'factory_bot_rails', '~> 6.4'
end
group :test do
gem 'capybara', '~> 3.39'
gem 'selenium-webdriver', '~> 4.18'
gem 'database_cleaner-active_record', '~> 2.1'
gem 'shoulda-matchers', '~> 5.3'
endInstallation
bundle install
rails generate rspec:installThis creates:
spec/spec_helper.rb— plain RSpec configurationspec/rails_helper.rb— Rails-specific configuration.rspec— default CLI flags
rails_helper.rb
This is where most of your Rails test configuration lives. A typical setup:
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require 'rspec/rails'
require 'capybara/rails'
require 'capybara/rspec'
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
ActiveRecord::Migration.maintain_test_schema!
RSpec.configure do |config|
config.fixture_path = "#{::Rails.root}/spec/fixtures"
config.use_transactional_fixtures = false # handled by DatabaseCleaner
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods
endspec_helper vs rails_helper
This confuses everyone at first.
spec_helper.rb — plain RSpec configuration. No Rails. Use it for specs that don't need Rails at all (pure Ruby classes, service objects with no Active Record dependencies). Keeps those specs fast.
rails_helper.rb — requires spec_helper plus the Rails environment. Use it for anything touching models, controllers, views, routes, or the database.
In practice, most people just always require rails_helper and accept the startup time. If you care about spec boot time (and you should for large apps), split your specs:
# spec/lib/pure_ruby_class_spec.rb
require 'spec_helper' # fast
# spec/models/user_spec.rb
require 'rails_helper' # full Rails bootDatabaseCleaner
When specs write to the database, you need to clean up between examples. There are two strategies:
- Transaction: wrap each example in a transaction and roll it back. Fast. Doesn't work with system specs (Capybara runs in a separate thread that can't see uncommitted transactions).
- Truncation: delete all rows after each example. Slower but works everywhere.
- Deletion: like truncation but uses DELETE instead of TRUNCATE. Slower still, respects foreign keys better.
Configure DatabaseCleaner in spec/support/database_cleaner.rb:
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.strategy = :transaction
end
config.before(:each, :js) do
DatabaseCleaner.strategy = :truncation
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
endThe :js metadata flag (set on system specs) switches to truncation strategy for those examples.
Require it in rails_helper.rb:
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }factory_bot
Factories define how to create test data. They're far superior to fixtures for most cases — factories are composable, explicit, and live next to your code.
Defining Factories
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
password { "password123" }
first_name { "Alice" }
last_name { "Smith" }
role { :member }
trait :admin do
role { :admin }
end
trait :confirmed do
confirmed_at { 1.day.ago }
end
trait :unconfirmed do
confirmed_at { nil }
end
end
end# spec/factories/posts.rb
FactoryBot.define do
factory :post do
sequence(:title) { |n| "Post #{n}" }
body { "Some content here" }
status { :draft }
association :author, factory: :user
trait :published do
status { :published }
published_at { 1.hour.ago }
end
end
endUsing Factories
# Build (no database hit)
user = build(:user)
user = build(:user, name: "Custom Name")
# Create (saves to database)
user = create(:user)
user = create(:user, :admin, :confirmed)
# Build stubbed (no database, all associations also stubbed)
user = build_stubbed(:user)
# Create a list
users = create_list(:user, 5)
users = create_list(:user, 3, :admin)
# Build a list
posts = build_list(:post, 10, :published)Use build for unit tests that don't need persistence. Use create when you need the record in the database (validations run on save, associations need IDs). Use build_stubbed for the fastest possible tests — it fakes persistence entirely.
Model Specs
Model specs test validations, scopes, callbacks, and business logic on Active Record models.
# spec/models/user_spec.rb
require 'rails_helper'
describe User, type: :model do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_presence_of(:password).on(:create) }
it { is_expected.to validate_length_of(:password).is_at_least(8) }
end
describe "associations" do
it { is_expected.to have_many(:posts).dependent(:destroy) }
it { is_expected.to have_many(:comments) }
it { is_expected.to belong_to(:organization).optional }
end
describe "scopes" do
describe ".active" do
let!(:active_user) { create(:user, :confirmed) }
let!(:inactive_user) { create(:user, :unconfirmed) }
it "returns only confirmed users" do
expect(User.active).to contain_exactly(active_user)
end
end
describe ".admins" do
let!(:admin) { create(:user, :admin) }
let!(:member) { create(:user) }
it "returns only admin users" do
expect(User.admins).to contain_exactly(admin)
end
end
end
describe "#full_name" do
subject(:user) { build(:user, first_name: "Alice", last_name: "Smith") }
it "joins first and last name" do
expect(user.full_name).to eq("Alice Smith")
end
end
describe "#confirm!" do
subject(:user) { create(:user, :unconfirmed) }
it "sets confirmed_at" do
expect { user.confirm! }.to change { user.confirmed_at }.from(nil)
end
it "returns true" do
expect(user.confirm!).to be true
end
end
endThe shoulda-matchers gem provides the validate_presence_of, have_many, belong_to and similar one-liner matchers. Add this to rails_helper.rb:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
endRequest Specs
Request specs test your API endpoints end-to-end: they hit the router, run through the full middleware stack, and return a real response. They replaced controller specs as the recommended way to test controllers in Rails 5+.
# spec/requests/users_spec.rb
require 'rails_helper'
describe "Users API", type: :request do
let(:user) { create(:user, :confirmed) }
let(:headers) { { "Authorization" => "Bearer #{user.auth_token}" } }
describe "GET /api/users" do
before { create_list(:user, 3) }
it "returns all users" do
get "/api/users", headers: headers
expect(response).to have_http_status(:ok)
expect(json_response["users"].length).to eq(4) # 3 + the auth user
end
it "returns user data with expected fields" do
get "/api/users", headers: headers
user_data = json_response["users"].first
expect(user_data).to include("id", "email", "first_name", "last_name")
end
context "without authentication" do
it "returns 401" do
get "/api/users"
expect(response).to have_http_status(:unauthorized)
end
end
end
describe "POST /api/users" do
let(:valid_params) do
{ user: { email: "new@example.com", password: "password123", first_name: "Bob", last_name: "Jones" } }
end
context "with valid parameters" do
it "creates a user" do
expect {
post "/api/users", params: valid_params, as: :json
}.to change { User.count }.by(1)
end
it "returns 201" do
post "/api/users", params: valid_params, as: :json
expect(response).to have_http_status(:created)
end
it "returns the created user" do
post "/api/users", params: valid_params, as: :json
expect(json_response["user"]["email"]).to eq("new@example.com")
end
end
context "with invalid parameters" do
let(:invalid_params) { { user: { email: "", password: "short" } } }
it "does not create a user" do
expect {
post "/api/users", params: invalid_params, as: :json
}.not_to change { User.count }
end
it "returns 422" do
post "/api/users", params: invalid_params, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it "returns validation errors" do
post "/api/users", params: invalid_params, as: :json
expect(json_response["errors"]).to include("email" => anything, "password" => anything)
end
end
end
endAdd this helper method in spec/support/request_helper.rb:
module RequestHelper
def json_response
JSON.parse(response.body)
end
end
RSpec.configure do |config|
config.include RequestHelper, type: :request
endSystem Specs
System specs (previously called feature specs) test your application through a real browser using Capybara. They're slow but they test the full stack including JavaScript.
# spec/system/user_registration_spec.rb
require 'rails_helper'
describe "User Registration", type: :system do
before do
driven_by(:rack_test) # use :selenium_chrome for JS
end
it "allows a new user to register" do
visit new_user_registration_path
fill_in "Email", with: "alice@example.com"
fill_in "Password", with: "password123"
fill_in "Password confirmation", with: "password123"
click_button "Sign up"
expect(page).to have_text("Welcome! You have signed up successfully.")
expect(page).to have_current_path(root_path)
end
it "shows errors for invalid input" do
visit new_user_registration_path
fill_in "Email", with: "not-an-email"
fill_in "Password", with: "short"
click_button "Sign up"
expect(page).to have_text("Email is invalid")
expect(page).to have_text("Password is too short")
end
endFor JavaScript-dependent tests:
describe "Dynamic search", type: :system, js: true do
before do
driven_by(:selenium_chrome_headless)
create_list(:product, 5, name: "Widget")
create(:product, name: "Gadget")
end
it "filters results as the user types" do
visit products_path
fill_in "Search", with: "Gad"
expect(page).to have_text("Gadget")
expect(page).not_to have_text("Widget")
end
endConfigure Capybara in spec/support/capybara.rb:
Capybara.configure do |config|
config.default_max_wait_time = 5
config.server = :puma, { Silent: true }
end
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by(:rack_test)
end
config.before(:each, type: :system, js: true) do
driven_by(:selenium_chrome_headless)
end
endThe Spec Directory Structure for Rails
spec/
├── rails_helper.rb
├── spec_helper.rb
├── factories/
│ ├── users.rb
│ ├── posts.rb
│ └── comments.rb
├── models/
│ ├── user_spec.rb
│ └── post_spec.rb
├── requests/
│ ├── users_spec.rb
│ └── posts_spec.rb
├── system/
│ ├── user_registration_spec.rb
│ └── checkout_spec.rb
├── services/
│ └── order_processor_spec.rb
├── lib/
│ └── some_utility_spec.rb
└── support/
├── database_cleaner.rb
├── capybara.rb
├── request_helper.rb
└── shared_examples/
└── api_endpoint.rbrspec-rails infers the spec type from the directory: files under spec/models/ get type: :model, spec/requests/ gets type: :request, and so on. This triggers the appropriate helpers automatically.
Generating Specs
rspec-rails hooks into Rails generators:
rails generate model User email:string
# creates: spec/models/user_spec.rb + spec/factories/users.rb
rails generate controller Users index show
# creates: spec/requests/users_spec.rb
rails generate scaffold Post title:string body:text
# creates model spec, request spec, and system specThese generated specs have the right structure but need actual test content. They're scaffolding, not finished tests.
Performance Tips
Rails test suites slow down over time. A few things that help:
Separate slow and fast specs. Tag system specs with :slow and exclude them from your everyday test run.
Use build_stubbed over create wherever possible. Database writes are expensive. If your spec doesn't need the record persisted, don't persist it.
Keep factory traits lean. Factories with deep association chains (factory :order that creates user, product, inventory_item) create a lot of objects. Use build_stubbed or define minimal factories for unit tests.
Parallelize. parallel_tests gem splits your suite across CPU cores. Huge speedup for large suites. Requires proper database configuration (separate database per process).
Profile slow specs. bundle exec rspec --format json --out tmp/results.json && cat tmp/results.json | jq '.examples | sort_by(.run_time) | reverse | .[0:10]' shows your ten slowest examples.