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"
endRun the installer:
bundle install
rails generate rspec:installThis creates:
.rspec— default flags like--format documentation --colorspec/spec_helper.rb— core RSpec configurationspec/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.rbThe 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
endThe naming convention matters. RSpec builds failure messages from your descriptions:
Article validations when title is blank is invalidUse 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
endlet 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
endlet 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) }
endThe 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: :testConfigure in rails_helper.rb:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
endNow 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) }
endThese 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"
endShared 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"
endRequest 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
endFor 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
endShared 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
endCustom 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
endConfiguring 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: :modelspec/controllers/→type: :controllerspec/requests/→type: :requestspec/system/→type: :systemspec/helpers/→type: :helperspec/mailers/→type: :mailerspec/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 ...endRun with --tag focus:
bundle exec rspec --tag focusCustom tags let you organize specs by feature or speed:
it "tests Stripe integration", :external do ...endbundle exec rspec --tag ~external # skip external specs
bundle exec rspec --tag external # run only external specsFormatting Output
The documentation formatter shows each example name:
bundle exec rspec --format documentationOutput:
Article
validations
when title is blank
is invalid
adds an error to title
#published?
returns true when status is publishedFor CI, use the progress formatter (default) with JUnit output for test result reporting:
gem "rspec_junit_formatter", group: :testbundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec.xmlRSpec'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.