Complete Guide to RSpec with Rails 7+: From Basics to Advanced Testing

RSpec is the most popular testing framework for Ruby and Rails applications. This comprehensive guide covers everything from basic RSpec syntax to advanced Rails 7+ testing patterns, with real-world examples and scenarios.

Table of Contents

  1. RSpec Basics
  2. Rails 7+ Integration
  3. Core RSpec Methods
  4. Testing Scenarios
  5. Advanced Features
  6. Best Practices

RSpec Basics

Basic Structure

require "rails_helper"

RSpec.describe Session::AppliedDiscount do
  # Test content goes here
end

Key Components:

  • require "rails_helper" – Loads Rails testing environment
  • RSpec.describe – Groups related tests
  • describe can take a class, string, or symbol

The Building Blocks

describe and context

RSpec.describe User do
  describe "#full_name" do
    context "when first and last name are present" do
      # tests here
    end

    context "when only first name is present" do
      # tests here
    end
  end

  describe ".active_users" do
    context "with active users in database" do
      # tests here
    end
  end
end

it – Individual Test Cases

it "returns the user's full name" do
  user = User.new(first_name: "John", last_name: "Doe")
  expect(user.full_name).to eq("John Doe")
end

it "handles missing last name gracefully" do
  user = User.new(first_name: "John")
  expect(user.full_name).to eq("John")
end

Core RSpec Methods

let and let!

Lazy Evaluation with let
RSpec.describe Session::Discount do
  let(:cookies) { CookiesStub.new }
  let(:code) { create_code(10) }
  let(:customer) { init_customer }
  let(:customer_code) { create_customer_code(customer) }

  it "uses lazy evaluation" do
    # code is only created when first accessed
    expect(code.amount).to eq(10)
  end
end
Immediate Evaluation with let!
let!(:user) { User.create(name: "John") }  # Created immediately
let(:profile) { user.profile }             # Created when accessed

it "has user already created" do
  expect(User.count).to eq(1)  # user already exists
end

subject

Implicit Subject
RSpec.describe User do
  let(:user_params) { { name: "John", email: "john@example.com" } }

  subject { User.new(user_params) }

  it { is_expected.to be_valid }
  it { is_expected.to respond_to(:full_name) }
end
Named Subject
describe '#initial_discount' do
  subject(:initial_discount_in_rupee) { 
    described_class.new(cookies: cookies).initial_discount_in_rupee 
  }

  it 'returns initial discount for customer' do
    accessor.set_customer_code(customer_code: customer_code)
    expect(initial_discount_in_rupee).to eq(expected_amount)
  end
end

expect and Matchers

Basic Matchers
# Equality
expect(user.name).to eq("John")
expect(user.age).to be > 18
expect(user.email).to include("@")

# Boolean checks
expect(user).to be_valid
expect(user.active?).to be true
expect(user.admin?).to be_falsy

# Type checks
expect(user.created_at).to be_a(Time)
expect(user.tags).to be_an(Array)
Collection Matchers
expect(users).to include(john_user)
expect(user.roles).to contain_exactly("admin", "user")
expect(shopping_cart.items).to be_empty
expect(search_results).to have(3).items
String Matchers
expect(user.email).to match(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
expect(response.body).to include("Welcome")
expect(error_message).to start_with("Error:")
expect(success_message).to end_with("successfully!")

Rails 7+ Integration

Rails Helper Setup

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'

abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'

RSpec.configure do |config|
  config.fixture_path = "#{::Rails.root}/spec/fixtures"
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!
end

Testing Controllers

RSpec.describe Api::V1::SessionsController, type: :controller do
  let(:user) { create(:user) }
  let(:valid_params) { { email: user.email, password: "password" } }

  describe "POST #create" do
    context "with valid credentials" do
      it "returns success response" do
        post :create, params: valid_params
        expect(response).to have_http_status(:success)
        expect(JSON.parse(response.body)["success"]).to be true
      end

      it "sets authentication token" do
        post :create, params: valid_params
        expect(response.cookies["auth_token"]).to be_present
      end
    end

    context "with invalid credentials" do
      it "returns unauthorized status" do
        post :create, params: { email: user.email, password: "wrong" }
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end

Testing Models

RSpec.describe User, type: :model do
  describe "validations" do
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_uniqueness_of(:email) }
    it { is_expected.to validate_length_of(:password).is_at_least(8) }
  end

  describe "associations" do
    it { is_expected.to have_many(:orders) }
    it { is_expected.to belong_to(:organization) }
    it { is_expected.to have_one(:profile) }
  end

  describe "scopes" do
    let!(:active_user) { create(:user, :active) }
    let!(:inactive_user) { create(:user, :inactive) }

    it "returns only active users" do
      expect(User.active).to include(active_user)
      expect(User.active).not_to include(inactive_user)
    end
  end
end

Testing Scenarios

Testing Service Objects

RSpec.describe Session::Discount do
  let(:cookies) { CookiesStub.new }
  let(:accessor) { Session::CookieDiscount.new(cookies) }

  describe '#initialize' do
    it 'calls ClearDiscountCode' do
      expect_any_instance_of(Session::ClearDiscountCode).to receive(:run)
      described_class.new(cookies: cookies)
    end

    it 'removes discount_code if referral_code presented' do
      accessor.set_code(discount)
      accessor.set_referral_code(referral_code: code)

      described_class.new(cookies: cookies)
      expect(accessor.discount).to be nil
    end
  end
end

Testing API Endpoints

RSpec.describe "API V1 Sessions", type: :request do
  let(:headers) { { "Content-Type" => "application/json" } }

  describe "POST /api/v1/sessions" do
    let(:user) { create(:user) }
    let(:params) do
      {
        session: {
          email: user.email,
          password: "password"
        }
      }
    end

    it "creates a new session" do
      post "/api/v1/sessions", params: params.to_json, headers: headers

      expect(response).to have_http_status(:created)
      expect(json_response["user"]["id"]).to eq(user.id)
      expect(json_response["token"]).to be_present
    end

    context "with invalid credentials" do
      before { params[:session][:password] = "wrong_password" }

      it "returns error" do
        post "/api/v1/sessions", params: params.to_json, headers: headers

        expect(response).to have_http_status(:unauthorized)
        expect(json_response["error"]).to eq("Invalid credentials")
      end
    end
  end
end

Testing Background Jobs

RSpec.describe EmailNotificationJob, type: :job do
  include ActiveJob::TestHelper

  let(:user) { create(:user) }

  describe "#perform" do
    it "sends welcome email" do
      expect {
        EmailNotificationJob.perform_now(user.id, "welcome")
      }.to change { ActionMailer::Base.deliveries.count }.by(1)
    end

    it "enqueues job" do
      expect {
        EmailNotificationJob.perform_later(user.id, "welcome")
      }.to have_enqueued_job(EmailNotificationJob)
    end
  end
end

Testing with Database Transactions

RSpec.describe OrderProcessor do
  describe "#process" do
    let(:order) { create(:order, :pending) }
    let(:payment_method) { create(:payment_method) }

    it "processes order successfully" do
      expect {
        OrderProcessor.new(order).process(payment_method)
      }.to change { order.reload.status }.from("pending").to("completed")
    end

    it "handles payment failures" do
      allow(payment_method).to receive(:charge).and_raise(PaymentError)

      expect {
        OrderProcessor.new(order).process(payment_method)
      }.to raise_error(PaymentError)

      expect(order.reload.status).to eq("failed")
    end
  end
end

Advanced Features

Shared Examples

# spec/support/shared_examples/auditable.rb
RSpec.shared_examples "auditable" do
  it "tracks creation" do
    expect(subject.created_at).to be_present
    expect(subject.created_by).to eq(current_user)
  end

  it "tracks updates" do
    subject.update(name: "Updated Name")
    expect(subject.updated_by).to eq(current_user)
  end
end

# Usage in specs
RSpec.describe User do
  let(:current_user) { create(:user) }
  subject { create(:user) }

  it_behaves_like "auditable"
end

Custom Matchers

# spec/support/matchers/be_valid_email.rb
RSpec::Matchers.define :be_valid_email do
  match do |actual|
    actual =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  end

  failure_message do |actual|
    "expected #{actual} to be a valid email address"
  end
end

# Usage
expect(user.email).to be_valid_email

Hooks and Callbacks

RSpec.describe User do
  before(:each) do
    @original_time = Time.current
    travel_to Time.zone.parse("2023-01-01 12:00:00")
  end

  after(:each) do
    travel_back
  end

  before(:all) do
    # Runs once before all tests in this describe block
    @test_data = create_test_data
  end

  around(:each) do |example|
    Rails.logger.silence do
      example.run
    end
  end
end

Stubbing and Mocking

describe "external API integration" do
  let(:api_client) { instance_double("APIClient") }

  before do
    allow(APIClient).to receive(:new).and_return(api_client)
  end

  it "calls external service" do
    expect(api_client).to receive(:get_user_data).with(user.id)
      .and_return({ name: "John", email: "john@example.com" })

    result = UserDataService.fetch(user.id)
    expect(result[:name]).to eq("John")
  end

  it "handles API errors gracefully" do
    allow(api_client).to receive(:get_user_data).and_raise(Net::TimeoutError)

    expect {
      UserDataService.fetch(user.id)
    }.to raise_error(ServiceUnavailableError)
  end
end

Testing Time-dependent Code

describe "subscription expiry" do
  let(:subscription) { create(:subscription, expires_at: 2.days.from_now) }

  it "is not expired when current" do
    expect(subscription).not_to be_expired
  end

  it "is expired when past expiry date" do
    travel_to 3.days.from_now do
      expect(subscription).to be_expired
    end
  end
end

Factory Bot Integration

Basic Factory Setup

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    first_name { "John" }
    last_name { "Doe" }
    password { "password123" }

    trait :admin do
      role { "admin" }
    end

    trait :with_profile do
      after(:create) do |user|
        create(:profile, user: user)
      end
    end

    factory :admin_user, traits: [:admin]
  end
end

# Usage in tests
let(:user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:user_with_profile) { create(:user, :with_profile) }

Advanced Factory Patterns

# spec/factories/orders.rb
FactoryBot.define do
  factory :order do
    user
    total_amount { 100.00 }
    status { "pending" }

    factory :completed_order do
      status { "completed" }
      completed_at { Time.current }

      after(:create) do |order|
        create_list(:order_item, 3, order: order)
      end
    end
  end
end

Testing Different Types

Feature Tests (System Tests)

RSpec.describe "User Registration", type: :system do
  it "allows user to register" do
    visit "/signup"

    fill_in "Email", with: "test@example.com"
    fill_in "Password", with: "password123"
    fill_in "Confirm Password", with: "password123"

    click_button "Sign Up"

    expect(page).to have_content("Welcome!")
    expect(page).to have_current_path("/dashboard")
  end
end

Mailer Tests

RSpec.describe UserMailer, type: :mailer do
  describe "#welcome_email" do
    let(:user) { create(:user) }
    let(:mail) { UserMailer.welcome_email(user) }

    it "sends to correct recipient" do
      expect(mail.to).to eq([user.email])
    end

    it "has correct subject" do
      expect(mail.subject).to eq("Welcome to Our App!")
    end

    it "includes user name in body" do
      expect(mail.body.encoded).to include(user.first_name)
    end
  end
end

Helper Tests

RSpec.describe ApplicationHelper, type: :helper do
  describe "#format_currency" do
    it "formats positive amounts" do
      expect(helper.format_currency(100.50)).to eq("$100.50")
    end

    it "handles zero amounts" do
      expect(helper.format_currency(0)).to eq("$0.00")
    end

    it "formats negative amounts" do
      expect(helper.format_currency(-50.25)).to eq("-$50.25")
    end
  end
end

Best Practices

1. Clear Test Structure

# Good: Clear, descriptive names
describe User do
  describe "#full_name" do
    context "when both names are present" do
      it "returns concatenated first and last name" do
        # test implementation
      end
    end
  end
end

# Bad: Unclear names
describe User do
  it "works" do
    # test implementation
  end
end

2. One Assertion Per Test

# Good: Single responsibility
it "validates email presence" do
  user = User.new(email: nil)
  expect(user).not_to be_valid
end

it "validates email format" do
  user = User.new(email: "invalid-email")
  expect(user).not_to be_valid
end

# Bad: Multiple assertions
it "validates email" do
  user = User.new(email: nil)
  expect(user).not_to be_valid

  user.email = "invalid-email"
  expect(user).not_to be_valid

  user.email = "valid@email.com"
  expect(user).to be_valid
end

3. Use let for Test Data

# Good: Reusable and lazy-loaded
let(:user) { create(:user, email: "test@example.com") }
let(:order) { create(:order, user: user, total: 100) }

it "calculates tax correctly" do
  expect(order.tax_amount).to eq(8.50)
end

# Bad: Repeated setup
it "calculates tax correctly" do
  user = create(:user, email: "test@example.com")
  order = create(:order, user: user, total: 100)
  expect(order.tax_amount).to eq(8.50)
end

4. Meaningful Error Messages

# Good: Custom error messages
expect(discount.amount).to eq(50), 
  "Expected discount amount to be $50 for premium users"

# Good: Descriptive matchers
expect(user.subscription).to be_active,
  "User subscription should be active after successful payment"

5. Test Edge Cases

describe "#divide" do
  it "divides positive numbers" do
    expect(calculator.divide(10, 2)).to eq(5)
  end

  it "handles division by zero" do
    expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
  end

  it "handles negative numbers" do
    expect(calculator.divide(-10, 2)).to eq(-5)
  end

  it "handles float precision" do
    expect(calculator.divide(1, 3)).to be_within(0.001).of(0.333)
  end
end

Rails 7+ Specific Features

Testing with ActionText

RSpec.describe Post, type: :model do
  describe "rich text content" do
    let(:post) { create(:post) }

    it "can store rich text content" do
      post.content = "<p>Hello <strong>world</strong></p>"
      expect(post.content.to_s).to include("Hello")
      expect(post.content.to_s).to include("<strong>world</strong>")
    end
  end
end

Testing with Active Storage

RSpec.describe User, type: :model do
  describe "avatar attachment" do
    let(:user) { create(:user) }
    let(:image) { fixture_file_upload("spec/fixtures/avatar.jpg", "image/jpeg") }

    it "can attach avatar" do
      user.avatar.attach(image)
      expect(user.avatar).to be_attached
      expect(user.avatar.content_type).to eq("image/jpeg")
    end
  end
end

Testing Hotwire/Turbo

RSpec.describe "Todo Management", type: :system do
  it "updates todo via turbo stream" do
    todo = create(:todo, title: "Original Title")

    visit todos_path
    click_link "Edit"
    fill_in "Title", with: "Updated Title"
    click_button "Update"

    expect(page).to have_content("Updated Title")
    expect(page).not_to have_content("Original Title")
    # Verify it was updated via AJAX, not full page reload
    expect(page).not_to have_selector(".flash-message")
  end
end

Configuration and Setup

RSpec Configuration

# spec/rails_helper.rb
RSpec.configure do |config|
  # Database cleaner
  config.use_transactional_fixtures = true

  # Factory Bot
  config.include FactoryBot::Syntax::Methods

  # Custom helpers
  config.include AuthenticationHelpers, type: :request
  config.include ControllerHelpers, type: :controller

  # Filtering
  config.filter_run_when_matching :focus
  config.example_status_persistence_file_path = "spec/examples.txt"

  # Parallel execution
  config.order = :random
  Kernel.srand config.seed
end

Database Cleaner Setup

# spec/rails_helper.rb
require 'database_cleaner/active_record'

RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end
end

This comprehensive guide covers the essential RSpec patterns you’ll use in Rails 7+ applications. The examples shown are based on real-world scenarios and follow current best practices for maintainable, reliable test suites.

Remember: Good tests are documentation for your code – they should clearly express what your application does and how it should behave under different conditions.