RSpec Rails Tutorial: Model Specs, Request Specs, System Specs
RSpec integrates deeply with Rails through the rspec-rails gem. It provides spec types tuned for each layer of a Rails application: model specs, request specs, system specs, and more. This guide shows you how to set up rspec-rails and write effective tests for all three layers.
Setup
Add to your Gemfile:
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
end
group :test do
gem 'database_cleaner-active_record'
gem 'shoulda-matchers'
gem 'capybara'
endRun:
bundle install
rails generate rspec:installThis creates:
.rspecspec/spec_helper.rbspec/rails_helper.rb
All Rails specs should require 'rails_helper' (not just spec_helper).
Model Specs
Model specs test ActiveRecord models: validations, associations, scopes, and instance methods.
File location: spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
describe 'validations' do
it 'is valid with a name and email' do
user = build(:user)
expect(user).to be_valid
end
it 'is invalid without a name' do
user = build(:user, name: nil)
expect(user).not_to be_valid
expect(user.errors[:name]).to include("can't be blank")
end
it 'is invalid without a unique email' do
create(:user, email: 'alice@example.com')
duplicate = build(:user, email: 'alice@example.com')
expect(duplicate).not_to be_valid
end
end
describe 'associations' do
it { is_expected.to have_many(:posts) }
it { is_expected.to belong_to(:organization) }
end
describe '#full_name' do
it 'combines first and last name' do
user = build(:user, first_name: 'Alice', last_name: 'Smith')
expect(user.full_name).to eq('Alice Smith')
end
end
describe '.active' do
it 'returns only active users' do
active = create(:user, active: true)
inactive = create(:user, active: false)
expect(User.active).to include(active)
expect(User.active).not_to include(inactive)
end
end
endWith shoulda-matchers, association and validation tests become one-liners:
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:email) }
it { is_expected.to have_many(:posts).dependent(:destroy) }
it { is_expected.to belong_to(:organization) }Request Specs
Request specs test your API and controller behavior through the full HTTP stack. They replaced controller specs as the recommended way to test Rails controllers.
File location: spec/requests/users_spec.rb
require 'rails_helper'
RSpec.describe 'Users API', type: :request do
describe 'GET /users' do
before { create_list(:user, 3) }
it 'returns all users' do
get '/users'
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).length).to eq(3)
end
end
describe 'POST /users' do
let(:valid_params) { { user: { name: 'Alice', email: 'alice@example.com' } } }
let(:invalid_params) { { user: { name: '', email: 'alice@example.com' } } }
context 'with valid params' do
it 'creates a user and returns 201' do
expect {
post '/users', params: valid_params
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
end
end
context 'with invalid params' do
it 'returns 422 with error details' do
post '/users', params: invalid_params
expect(response).to have_http_status(:unprocessable_entity)
body = JSON.parse(response.body)
expect(body['errors']).to be_present
end
end
end
describe 'DELETE /users/:id' do
let!(:user) { create(:user) }
context 'when user exists' do
it 'deletes the user' do
expect {
delete "/users/#{user.id}"
}.to change(User, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
end
context 'when user does not exist' do
it 'returns 404' do
delete '/users/99999'
expect(response).to have_http_status(:not_found)
end
end
end
endTesting with Authentication
For JWT or token-based auth, include the token in headers:
let(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
let(:headers) { { 'Authorization' => "Bearer #{token}" } }
it 'returns the current user' do
get '/profile', headers: headers
expect(response).to have_http_status(:ok)
endSystem Specs
System specs (formerly called feature specs) test your application through a real browser using Capybara. They cover the full stack from UI to database.
File location: spec/system/sign_in_spec.rb
require 'rails_helper'
RSpec.describe 'Sign In', type: :system do
let(:user) { create(:user, email: 'alice@example.com', password: 'password123') }
before do
driven_by(:rack_test) # or :selenium_chrome_headless for JS
end
it 'signs in with valid credentials' do
visit sign_in_path
fill_in 'Email', with: user.email
fill_in 'Password', with: 'password123'
click_button 'Sign In'
expect(page).to have_text('Welcome back, Alice')
expect(current_path).to eq(dashboard_path)
end
it 'shows an error with invalid credentials' do
visit sign_in_path
fill_in 'Email', with: user.email
fill_in 'Password', with: 'wrongpassword'
click_button 'Sign In'
expect(page).to have_text('Invalid email or password')
expect(current_path).to eq(sign_in_path)
end
endFor JavaScript-heavy UIs, switch to driven_by(:selenium_chrome_headless).
Database Cleaning
Tests that create database records need cleanup between examples. Configure database_cleaner 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: true) do
DatabaseCleaner.strategy = :truncation
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
endRequire it from rails_helper.rb:
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }Shared Examples
Extract reusable behavior into shared examples:
# spec/support/shared_examples/authenticatable.rb
RSpec.shared_examples 'requires authentication' do
context 'without auth token' do
it 'returns 401' do
make_request
expect(response).to have_http_status(:unauthorized)
end
end
end
# In your request spec:
RSpec.describe 'Posts', type: :request do
describe 'GET /posts' do
let(:make_request) { get '/posts' }
include_examples 'requires authentication'
end
endSpec Directory Layout
spec/
models/
user_spec.rb
post_spec.rb
requests/
users_spec.rb
posts_spec.rb
system/
sign_in_spec.rb
post_creation_spec.rb
factories/
users.rb
posts.rb
support/
database_cleaner.rb
factory_bot.rb
shoulda_matchers.rb
rails_helper.rb
spec_helper.rbRunning Specific Spec Types
# All model specs
bundle <span class="hljs-built_in">exec rspec spec/models
<span class="hljs-comment"># All request specs
bundle <span class="hljs-built_in">exec rspec spec/requests
<span class="hljs-comment"># System specs
bundle <span class="hljs-built_in">exec rspec spec/system
<span class="hljs-comment"># Tag-based (add metadata to specs)
bundle <span class="hljs-built_in">exec rspec --tag focusTag a spec for focused running:
it 'does something', :focus do
# ...
endKey Configuration Tips
In rails_helper.rb, enable FactoryBot syntax:
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
endConfigure shoulda-matchers:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
endWhat to Test at Each Layer
| Layer | Test via | Focus |
|---|---|---|
| Model | Model spec | Validations, associations, scopes, methods |
| Controller/API | Request spec | HTTP status, response body, side effects |
| UI flows | System spec | User journeys, form submission, navigation |
| Background jobs | Job spec | Arguments, enqueuing, execution |
| Mailers | Mailer spec | Recipients, subject, body content |
Test the right behavior at the right layer. Keep system specs for critical user flows — they're slower. Cover edge cases and error paths in model and request specs where execution is faster.