RSpec with Rails: Request Specs, Model Specs, System Specs, and More

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

Installation

bundle install
rails generate rspec:install

This creates:

  • spec/spec_helper.rb — plain RSpec configuration
  • spec/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
end

spec_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 boot

DatabaseCleaner

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
end

The :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
end

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

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

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

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

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

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

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

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

rspec-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 spec

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

Read more

Start now free