RSpec Rails Tutorial: Model Specs, Request Specs, System Specs

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'
end

Run:

bundle install
rails generate rspec:install

This creates:

  • .rspec
  • spec/spec_helper.rb
  • spec/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
end

With 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
end

Testing 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)
end

System 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
end

For 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
end

Require 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
end

Spec 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.rb

Running 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 focus

Tag a spec for focused running:

it 'does something', :focus do
  # ...
end

Key Configuration Tips

In rails_helper.rb, enable FactoryBot syntax:

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

Configure shoulda-matchers:

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

What 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.

Read more