
This post is part of the Behavior Testing in Ruby with RSpec series
- 1Behaviour Driven Development in Ruby with RSpec
- 2RSpec Tests in Ruby on Rails: Every Spec Type Explained (2026)
RSpec is the most widely used testing framework for Ruby on Rails. With the rspec-rails gem (v8.x, supporting Rails 7.2–8.x), you write model specs for business logic, request specs for controllers and APIs, and system specs for browser flows — plus mailer and job specs. Run the suite with bundle exec rspec.
In the first part of this series, we looked at the basics of RSpec and how well it works with Behavior Driven Development in Ruby.
In this part, we’ll set up rspec-rails in a Rails 8 application and walk through each spec type with runnable examples.
Unit, Controller, and Integration Tests in Ruby on Rails
A Ruby on Rails application is composed of several layers. The framework is built around models, views, and controllers, but that barely covers a real application. Mailers, jobs, and helpers are secondary layers you don’t want to miss.
How testable these layers are depends on how you design your code. Following SOLID principles, particularly Single Responsibility, keeps classes small and straightforward to test. Test each brick of code on its own, then keep the code in controllers, views, and jobs thin: they should assemble bricks you have already tested. The closer you get to the browser, the more complex and slow testing becomes.
Categories of Ruby Tests
RSpec organizes Rails tests into spec types, one per layer. Here is what each covers, and how much of it you should write:
- Model specs (unit tests): Cover your models’ public methods and any plain old Ruby objects (POROs). Write many of these, keep them focused, and avoid database access where you can. They should run fast.
- Request specs: Cover the controller layer from a machine client’s perspective: “for a given HTTP verb, path, and parameters, what response should we get?” They are the standard way to test controllers and APIs.
- System specs: Simulate a human user in a browser with Capybara. Reserve them for scenarios that matter, like a signup or checkout flow. They exercise the whole stack, so they are slow.
- Mailer and job specs: Confirm that emails carry the right headers and content, and that jobs get enqueued when they should. The heavy lifting behind both belongs in classes with their own unit tests.
- Helper, view, and routing specs: Rarely needed. Reach for them when a helper grows complex, a view fragment carries logic, or your routing does. Complex routing is a code smell in itself.
Writing every possible spec type for every change causes fatigue and a slow suite for little benefit. Cover each layer once, in the cheapest spec type that can catch the regression.
First, install RSpec.
Using RSpec and Ruby on Rails for Testing
The rspec-rails gem integrates RSpec into a Rails application. Add it to the :development and :test groups in your Gemfile:
# Gemfile
group :development, :test do
gem "rspec-rails", "~> 8.0"
endThen install the gem and run the install generator:
bundle install
bin/rails generate rspec:installThe generator creates the pieces RSpec needs in a Rails context:
create .rspec
create spec
create spec/spec_helper.rb
create spec/rails_helper.rb
spec_helper.rb configures RSpec itself, while rails_helper.rb loads the Rails environment on top. Specs that touch Rails require rails_helper; specs for plain Ruby classes can require the lighter spec_helper instead.
A note on versions: we verified everything in this post with rspec-rails 8.0.4 on Rails 8.1 and Ruby 3.4. The gem requires Rails 7.2 or later, so version 8.x covers Rails 7.2 through 8.x. Since version 7, rspec-rails versions independently of RSpec itself: the 8.x series pulls in the current 3.13 line of rspec-core, rspec-expectations, and rspec-mocks.
From here on, one command runs your whole suite:
bundle exec rspecWhat Types of Specs Does rspec-rails Support?
rspec-rails hooks into the Rails generators, so scaffolding a model or controller also scaffolds the matching spec. You can generate each spec type directly too:
| Spec type | What it tests | Speed | Generator command |
|---|---|---|---|
| Model | Business logic, validations, POROs | Fast | bin/rails generate rspec:model |
| Request | Controller actions, routing, APIs over HTTP | Fast | bin/rails generate rspec:request |
| System | Full user flows in a (headless) browser | Slow | bin/rails generate rspec:system |
| Mailer | Email headers and body content | Fast | bin/rails generate rspec:mailer |
| Job | Job enqueueing and behavior | Fast | bin/rails generate rspec:job |
| Helper | View helper methods | Fast | bin/rails generate rspec:helper |
| View | Rendered view fragments | Medium | bin/rails generate rspec:view |
| Controller (legacy) | Single controller actions in isolation | Medium | bin/rails generate rspec:controller |
Request specs supersede controller specs. Controller specs remain available for legacy suites, but both the Rails and RSpec teams recommend request specs instead, and helpers like assigns now live in the separate rails-controller-testing gem. Write request specs for new code.
Unit Tests
Unit tests sit at the base of the testing pyramid, so a codebase holds many of them. They need to be fast.
Remember: RSpec tests focus on code behavior, not implementation. In a Rails project, models are the biggest source of unit tests. Models talk to the database, and database access is what makes model specs slow. You can’t always avoid create, save, update, or find, but in most examples, new is enough.
Here is a model spec for a Subscription model with a plan_name presence validation and an #active? method:
# spec/models/subscription_spec.rb
require "rails_helper"
RSpec.describe Subscription, type: :model do
describe "validations" do
it "requires a plan name" do
subscription = Subscription.new(plan_name: nil)
expect(subscription).not_to be_valid
end
end
describe "#active?" do
context "when the subscription is active" do
subject(:subscription) { Subscription.new(plan_name: "pro", active: true) }
it { expect(subscription.active?).to be(true) }
end
context "when the subscription is canceled" do
subject(:subscription) { Subscription.new(plan_name: "pro", active: false) }
it "returns false" do
expect(subscription.active?).to be(false)
end
end
end
endThe two #active? examples do the same kind of thing in two styles. The one-liner has no description and still reads nicely; the expanded form carries a description at the cost of some duplication. Pick one style per context and stay consistent.
We use Subscription.new throughout. Nothing here reads from or writes to the database, so these examples run in microseconds. That’s what a good unit test looks like: focused, fast, and independent.
If you write many validation and association examples, the shoulda-matchers gem collapses them into one-liners — we covered it in how to use shoulda-matchers with RSpec for Ruby on Rails.
Unit tests aren’t only for models. Any PORO you build around your models deserves them too. Drop the type: :model metadata and require spec_helper instead of rails_helper, and the same style applies to plain Ruby classes.
Controller and Request Specs
Until a few years ago, controller specs were the main way to test controllers. Today, request specs have replaced them. Request specs test your application from a machine client’s perspective, through the full stack: routing, middleware, controller, and view.
A request spec defines a context made of an HTTP verb, a path, and (optionally) parameters and headers. The expectation then targets the response: its status code, headers, and body.
The simplest request spec checks that an endpoint responds:
# spec/requests/subscriptions_spec.rb
require "rails_helper"
RSpec.describe "Subscriptions", type: :request do
describe "GET /subscriptions" do
it "responds with a successful status" do
get "/subscriptions"
expect(response).to have_http_status(:ok)
end
end
endThe context lives in the line doing the request: get "/subscriptions". The expectation centers on response, an object RSpec exposes for exactly this purpose.
Request specs shine when testing API endpoints. Here we post JSON to create a subscription, then check both the happy path and a validation failure:
# spec/requests/subscriptions_spec.rb
require "rails_helper"
RSpec.describe "Subscriptions", type: :request do
describe "POST /subscriptions" do
it "creates a subscription and returns its details as JSON" do
post "/subscriptions",
params: { subscription: { plan_name: "pro", active: true } },
as: :json
expect(response).to have_http_status(:created)
expect(response.parsed_body["plan_name"]).to eq("pro")
end
it "rejects a subscription without a plan name" do
post "/subscriptions",
params: { subscription: { plan_name: "", active: true } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
endas: :json sets the content type and encodes the parameters, and response.parsed_body decodes the JSON response for you. The full RSpec vocabulary of describe, context, and let still applies; request specs only add helpers for the HTTP layer.
System Specs
System specs are the most complete integration tests. They drive your application in a real or headless browser through Capybara, which needs a driver. The default driver is Selenium; driven_by changes it per spec or suite-wide.
Where request specs speak HTTP, system specs speak user: you visit pages, fill in forms, and click buttons. Here is a system spec for a signup flow, using the :rack_test driver, which needs no browser at all:
# spec/system/subscriptions_spec.rb
require "rails_helper"
RSpec.describe "Subscription signup", type: :system do
before do
driven_by :rack_test
end
it "lets a customer pick a plan" do
visit "/subscriptions/new"
fill_in "Plan name", with: "pro"
click_button "Create Subscription"
expect(page).to have_text("Subscription created.")
end
endThe HTTP context is still there, underneath: visit makes a GET request, filling the form sets parameters, and clicking the button triggers a POST. The expectation reads the rendered page rather than a raw response body.
The :rack_test driver is fast but doesn’t execute JavaScript. When a flow depends on JavaScript, switch that spec to a real browser with driven_by :selenium, using: :headless_chrome.
Because system specs drive the complete stack, they run far slower than other specs. Keep them few, reserve them for your most valuable flows, and consider running them in a separate CI step.
Complementary Test Types in Ruby
Beyond models, controllers, and browser flows, Rails applications carry components with specific roles: mailers, jobs, serializers, and decorators. These need tests closer to unit tests than integration tests, and rspec-rails provides dedicated spec types for the first two.
Mailer Specs
Action Mailer abstracts the delivery of email away from your code, so you don’t need to test that emails get sent. Instead, test the behavior that’s yours: the subject, the from and to addresses, and the body.
# spec/mailers/renewal_mailer_spec.rb
require "rails_helper"
RSpec.describe RenewalMailer, type: :mailer do
describe "reminder" do
let(:mail) { RenewalMailer.reminder }
it "prepares the email headers" do
expect(mail.subject).to eq("Your subscription renews soon")
expect(mail.to).to eq(["customer@example.org"])
expect(mail.from).to eq(["billing@example.com"])
end
it "renders the body" do
expect(mail.body.encoded).to match("renews in seven days")
end
end
endCalling the mailer method returns the mail object without delivering anything, so these specs stay fast.
Job Specs
Job specs cover the queueing behavior of your background jobs: whether a job gets enqueued, on which queue, and with which arguments. Active Job already has its own tests; you don’t need to prove that perform_later enqueues. What you want to know is whether your code enqueues the right job at the right time.
Set the queue adapter to :test and use the matchers rspec-rails provides:
# spec/jobs/subscription_sync_job_spec.rb
require "rails_helper"
RSpec.describe SubscriptionSyncJob, type: :job do
before do
ActiveJob::Base.queue_adapter = :test
end
it "enqueues the job on the billing queue" do
expect {
SubscriptionSyncJob.perform_later(42)
}.to have_enqueued_job(SubscriptionSyncJob).with(42).on_queue("billing")
end
it "records the job as enqueued exactly once" do
SubscriptionSyncJob.perform_later(42)
expect(SubscriptionSyncJob).to have_been_enqueued.with(42).exactly(:once)
end
endhave_enqueued_job wraps a block and checks what the block enqueues; have_been_enqueued checks after the fact. Keep the job’s perform method thin: it should call classes covered by their own unit tests. And once those jobs run in production, AppSignal instruments background jobs for Sidekiq, Solid Queue, and friends, so you can watch queue times and failures per job class.
A Note on Concerns in Ruby on Rails
Rails lets you share code across models or controllers through a disguised Ruby module called a concern. Test a concern’s behavior through what includes it: model specs cover concerns in models, while request and system specs cover concerns in controllers.
Some Thoughts on Testing with RSpec for Ruby
Your first layer of testing should be unit tests. Model and PORO specs should represent the vast majority of specs in a codebase.
Specs should run fast and avoid database and external service access as much as possible. To test the controller layer, use request specs for machine-driven activity and system specs for human-driven flows. Both cost more than unit tests, so keep them fewer in number.
Finally, keep mailer and job specs in their specific lane: test your headers, bodies, and queueing decisions, not the behavior of Action Mailer or Active Job themselves.
A green suite doesn’t guarantee a fast app — and a slow, flaky suite often mirrors slow, flaky production code. AppSignal’s Ruby monitoring traces the requests and background jobs your specs exercise, so you can see how they actually behave after deploy.
Wrapping Up
As we saw in part one of this series, RSpec’s building blocks (before hooks, let, describe, and context) help you avoid duplication across all of these spec types. Keeping unit tests small comes naturally; keeping request and system specs slim takes work. After your specs turn green, spend some time trimming them down. Future you will thank you when the suite stays readable and fast.
Happy testing!
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
This post is part of the Behavior Testing in Ruby with RSpec series
- 1Behaviour Driven Development in Ruby with RSpec
- 2RSpec Tests in Ruby on Rails: Every Spec Type Explained (2026)
Frequently asked questions
- How do I install rspec-rails in Rails 8?
- Add the rspec-rails gem to the development and test groups in your Gemfile, run bundle install, then run rails generate rspec:install. The generator creates the .rspec file plus spec/spec_helper.rb and spec/rails_helper.rb. rspec-rails 8 supports Rails 7.2 and later.
- What is the difference between request specs and system specs in RSpec?
- Request specs send HTTP requests through the full Rails stack without a browser, so they run fast and suit APIs. System specs drive a browser through Capybara, so they can cover JavaScript and multi-step user flows, but they run much slower.
- What types of specs does rspec-rails support?
- rspec-rails ships generators for model, request, system, mailer, job, helper, view, routing, controller, and feature specs. Most Rails suites rely on three of them: model specs for business logic, request specs for controllers and APIs, and a small set of system specs.
- Should I use controller specs or request specs in Rails?
- Use request specs. The Rails and RSpec teams both recommend them over controller specs, which need the separate rails-controller-testing gem for older helpers. Request specs run through routing and middleware, so they test what production traffic hits.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Thomas Riboulet
Our guest author Thomas is a Consultant Backend and Cloud Infrastructure Engineer based in France. For over 13 years, he has worked with startups and companies to scale their teams, products, and infrastructure. He has also been published several times in France's GNU/Linux magazine and on his blog.
All articles by Thomas RibouletBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


