RSpec Rails Tutorial: BDD Testing for Rails Applications

RSpec Rails Tutorial: BDD Testing for Rails Applications

RSpec is the dominant testing framework in the Rails ecosystem. Its BDD-style syntax encourages you to write tests that read like specifications, which makes test failures easier to diagnose and test suites easier to navigate. This tutorial walks through everything you need to get RSpec working well in a Rails application.

Setting Up RSpec in Rails

Add the rspec-rails gem to your Gemfile:

group :development, :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
  gem "faker"
end

Run the installer:

bundle install
rails generate rspec:install

This creates:

  • .rspec — default flags like --format documentation --color
  • spec/spec_helper.rb — core RSpec configuration
  • spec/rails_helper.rb — Rails-specific configuration

After setup, generators will create specs automatically:

rails generate model Article title:string body:text
# creates spec/models/article_spec.rb

The describe/context/it Structure

RSpec organizes tests into nested blocks. Each level adds context:

RSpec.describe Article, type: :model do
  describe "validations" do
    context "when title is present" do
      it "is valid" do
        article = build(:article, title: "Hello World")
        expect(article).to be_valid
      end
    end

    context "when title is blank" do
      it "is invalid" do
        article = build(:article, title: "")
        expect(article).not_to be_valid
      end

      it "adds an error to title" do
        article = build(:article, title: "")
        article.valid?
        expect(article.errors[:title]).to include("can't be blank")
      end
    end
  end
end

The naming convention matters. RSpec builds failure messages from your descriptions:

Article validations when title is blank is invalid

Use describe for methods or components. Use context for conditions. Use it for individual assertions.

A common pattern: describe "#method_name" for instance methods and describe ".class_method" for class methods:

RSpec.describe Article, type: :model do
  describe "#published?" do
    it "returns true when status is published" do
      article = build(:article, status: "published")
      expect(article.published?).to be true
    end

    it "returns false when status is draft" do
      article = build(:article, status: "draft")
      expect(article.published?).to be false
    end
  end

  describe ".recent" do
    it "returns articles from the last 30 days" do
      recent = create(:article, created_at: 15.days.ago)
      old = create(:article, created_at: 45.days.ago)

      expect(Article.recent).to include(recent)
      expect(Article.recent).not_to include(old)
    end
  end
end

let and subject

let defines a lazy-evaluated variable that's memoized within an example:

RSpec.describe Article, type: :model do
  let(:author) { create(:user) }
  let(:article) { build(:article, author: author) }

  it "belongs to an author" do
    expect(article.author).to eq(author)
  end
end

let is lazy — the block runs only when the variable is first referenced. If a test doesn't use author, that user is never created.

let! forces eager evaluation — the block runs before the test, even if unused:

let!(:published_article) { create(:article, status: "published") }

Use let! when you need a record in the database before the test runs (e.g., to test that a scope excludes it).

subject defines the object under test:

RSpec.describe Article, type: :model do
  subject { build(:article) }

  it { is_expected.to validate_presence_of(:title) }
  it { is_expected.to belong_to(:author) }
end

The one-liner it { is_expected.to ... } syntax works with subject and Shoulda Matchers (covered below). It's concise but use it judiciously — overly compressed tests hide intent.

Matchers

RSpec ships with a rich set of matchers. The most common ones:

# equality
expect(result).to eq(42)
expect(result).to eql(42)      # strict type check
expect(result).to be(42)       # object identity

# truthiness
expect(flag).to be true
expect(flag).to be_truthy      # anything truthy
expect(flag).to be_nil

# comparison
expect(value).to be > 10
expect(value).to be_between(1, 100)

# collections
expect(array).to include("item")
expect(array).to contain_exactly("a", "b", "c")
expect(hash).to have_key(:name)

# strings
expect(string).to match(/pattern/)
expect(string).to start_with("Hello")
expect(string).to include("world")

# errors
expect { dangerous_call }.to raise_error(ArgumentError)
expect { dangerous_call }.to raise_error(ArgumentError, "message")

# change
expect { create(:article) }.to change(Article, :count).by(1)
expect { order.complete! }.to change(order, :status).from("pending").to("completed")

Shoulda Matchers

Add shoulda-matchers for Rails-specific matchers:

gem "shoulda-matchers", group: :test

Configure in rails_helper.rb:

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

Now you can write:

RSpec.describe Article, type: :model do
  it { is_expected.to validate_presence_of(:title) }
  it { is_expected.to validate_length_of(:title).is_at_most(200) }
  it { is_expected.to validate_uniqueness_of(:slug) }
  it { is_expected.to belong_to(:author) }
  it { is_expected.to have_many(:comments).dependent(:destroy) }
end

These test the Rails validation and association macros, not the database behavior — they're fast and expressive.

Shared Examples

When the same behavior appears across multiple specs, extract it into shared examples:

# spec/support/shared_examples/publishable.rb
RSpec.shared_examples "publishable" do
  describe "#publish!" do
    it "sets status to published" do
      expect { subject.publish! }.to change(subject, :status).to("published")
    end

    it "sets published_at to now" do
      freeze_time do
        subject.publish!
        expect(subject.published_at).to eq(Time.current)
      end
    end
  end
end

# in model specs
RSpec.describe Article, type: :model do
  subject { build(:article) }
  it_behaves_like "publishable"
end

RSpec.describe Page, type: :model do
  subject { build(:page) }
  it_behaves_like "publishable"
end

Shared examples accept parameters:

RSpec.shared_examples "requires authentication" do |method, path|
  it "redirects unauthenticated users" do
    send(method, path)
    expect(response).to redirect_to(login_path)
  end
end

RSpec.describe ArticlesController, type: :request do
  it_behaves_like "requires authentication", :get, "/articles/new"
  it_behaves_like "requires authentication", :post, "/articles"
end

Request Specs vs Controller Specs

Rails deprecated controller specs in favor of request specs. The difference:

Controller specs (deprecated): test controllers in isolation, without routing middleware. They stub too much and test the wrong things.

Request specs: test the full HTTP stack — routing, middleware, controller, response. They're what you want.

RSpec.describe "Articles", type: :request do
  describe "GET /articles" do
    let!(:articles) { create_list(:article, 3, status: "published") }

    it "returns the articles list" do
      get "/articles"

      expect(response).to have_http_status(:ok)
      expect(response.body).to include(articles.first.title)
    end
  end

  describe "POST /articles" do
    context "when authenticated" do
      let(:user) { create(:user) }

      before { sign_in user }

      it "creates an article" do
        expect {
          post "/articles", params: { article: attributes_for(:article) }
        }.to change(Article, :count).by(1)

        expect(response).to redirect_to(assigns(:article))
      end
    end

    context "when not authenticated" do
      it "redirects to login" do
        post "/articles", params: { article: attributes_for(:article) }
        expect(response).to redirect_to(login_path)
      end
    end
  end
end

For JSON APIs, request specs parse the response body:

RSpec.describe "Articles API", type: :request do
  describe "GET /api/v1/articles/:id" do
    let(:article) { create(:article, :published) }

    it "returns the article as JSON" do
      get "/api/v1/articles/#{article.id}",
          headers: { "Authorization" => "Bearer #{api_token}" }

      expect(response).to have_http_status(:ok)
      expect(response.content_type).to match("application/json")

      json = JSON.parse(response.body)
      expect(json["title"]).to eq(article.title)
      expect(json["status"]).to eq("published")
    end
  end
end

Shared Contexts

Shared contexts set up common state for a group of specs:

# spec/support/shared_contexts/authenticated.rb
RSpec.shared_context "authenticated as admin" do
  let(:admin) { create(:user, :admin) }

  before do
    sign_in admin
  end
end

# use in specs
RSpec.describe "Admin dashboard", type: :request do
  include_context "authenticated as admin"

  it "shows the dashboard" do
    get "/admin"
    expect(response).to have_http_status(:ok)
  end
end

Custom Helpers

Put shared helper methods in spec/support/:

# spec/support/authentication_helpers.rb
module AuthenticationHelpers
  def sign_in(user)
    post "/sessions", params: { email: user.email, password: "password" }
  end

  def auth_headers(user)
    token = JsonWebToken.encode(user_id: user.id)
    { "Authorization" => "Bearer #{token}" }
  end
end

RSpec.configure do |config|
  config.include AuthenticationHelpers, type: :request
end

Configuring Spec Types

RSpec infers spec types from file locations when you add this to rails_helper.rb:

config.infer_spec_type_from_file_location!
  • spec/models/type: :model
  • spec/controllers/type: :controller
  • spec/requests/type: :request
  • spec/system/type: :system
  • spec/helpers/type: :helper
  • spec/mailers/type: :mailer
  • spec/jobs/type: :job

You can also set it explicitly: RSpec.describe MyClass, type: :model do.

Focus and Tags

Run a subset of specs during development:

# focus a single spec
fit "this test runs" do ...end

# or use :focus tag
it "this test runs", :focus do ...end

Run with --tag focus:

bundle exec rspec --tag focus

Custom tags let you organize specs by feature or speed:

it "tests Stripe integration", :external do ...end
bundle exec rspec --tag ~external  # skip external specs
bundle exec rspec --tag external   # run only external specs

Formatting Output

The documentation formatter shows each example name:

bundle exec rspec --format documentation

Output:

Article
  validations
    when title is blank
      is invalid
      adds an error to title
  #published?
    returns true when status is published

For CI, use the progress formatter (default) with JUnit output for test result reporting:

gem "rspec_junit_formatter", group: :test
bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec.xml

RSpec's structure and rich matchers make test failures self-describing. Combined with tools like HelpMeTest for continuous end-to-end monitoring, your Rails test suite becomes both a development tool and a safety net for production.

Read more

Start now free