Request Specs vs Controller Specs — What to Test at Each Layer
Controller specs let you call an action directly and assert on assigned instance variables. They’re fast, they’re focused, and the Rails team has been steering people away from them for years. The reason isn’t fashion — it’s that a controller spec can pass while the endpoint is broken, because it skips routing, middleware, and rendering. Knowing what each layer actually covers tells you where a given assertion belongs.
What Each One Exercises
| Controller spec | Request spec | System spec | |
|---|---|---|---|
| Routing | ✗ | ✓ | ✓ |
| Middleware | ✗ | ✓ | ✓ |
| Controller action | ✓ | ✓ | ✓ |
| View rendering | opt-in | ✓ | ✓ |
| Real HTTP verbs and headers | simulated | ✓ | ✓ |
| JavaScript | ✗ | ✗ | ✓ |
| Speed | fastest | fast | slow |
The gap that matters is the top two rows. A controller spec invokes the action directly, so a route you deleted, a constraint that doesn’t match, or a middleware that rejects the request are all invisible to it.
Example:
# This passes even if the route was removed from routes.rb
RSpec.describe OrdersController, type: :controller do
it "assigns the order" do
get :show, params: { id: order.id }
expect(assigns(:order)).to eq(order)
end
end
# This fails immediately if the route is gone
RSpec.describe "Orders", type: :request do
it "renders the order" do
get order_path(order)
expect(response).to have_http_status(:ok)
expect(response.body).to include(order.sku)
end
end
assigns was extracted to a gem in Rails 5 for exactly this reason: asserting on a controller’s instance variables tests an implementation detail, not a behaviour. Rename @order to @record and the endpoint works identically while the spec fails.
Write Request Specs
For essentially all controller-layer testing.
Example:
RSpec.describe "Orders API", type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:headers) { { "Authorization" => "Bearer #{user.api_token}", "Accept" => "application/json" } }
describe "POST /api/orders" do
let(:valid_params) { { order: { sku: "ABC-1", quantity: 2 } } }
it "creates an order and returns 201" do
expect {
post "/api/orders", params: valid_params, headers:, as: :json
}.to change(Order, :count).by(1)
expect(response).to have_http_status(:created)
expect(response.parsed_body["sku"]).to eq("ABC-1")
expect(response.headers["Location"]).to eq(api_order_url(Order.last))
end
it "returns 422 with field errors when invalid" do
post "/api/orders", params: { order: { sku: "" } }, headers:, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body.dig("error", "details")).to have_key("sku")
end
it "returns 401 without a token" do
post "/api/orders", params: valid_params, as: :json
expect(response).to have_http_status(:unauthorized)
end
it "returns 404 for another account's order" do
other = create(:order)
get "/api/orders/#{other.id}", headers:, as: :json
expect(response).to have_http_status(:not_found)
end
end
end
Five things worth copying from that:
response.parsed_body— Rails parses JSON responses automatically, so noJSON.parseboilerplateas: :jsonsets the content type properly, which matters for how params are parsed- The 404-not-403 assertion — a scoped lookup should hide another account’s records entirely, and this is the spec that proves authorization is enforced at the query rather than after it
- Path helpers or literal paths, both real routes — a typo fails the test
- Testing the status code, the body, and the side effect separately
What Belongs at Each Layer
Request specs
- Status codes for success, validation failure, unauthenticated, unauthorized, not found
- Response shape for APIs — the contract your clients depend on
- Authorization boundaries — this is the most valuable thing they test
- Redirect targets and flash messages
- Params handling, including strong parameter filtering
- Custom headers, content negotiation, pagination metadata
Model / service specs
- Business logic, validations, state transitions, calculations
- Anything you’d otherwise test through six different endpoints
System specs
- The rendered UI, JavaScript behaviour, and multi-step user flows
The common anti-pattern is testing business rules through the controller: twelve request specs hitting POST /orders with different attribute combinations to exercise validation. That’s a model spec wearing a costume — slower, harder to read, and it fails for the wrong reason when the route changes. Test the rule once in the model, and test that the controller surfaces errors correctly once in a request spec.
Rendering Views
Request specs render views by default, which catches a real class of bug — a view referencing a method that no longer exists produces a 500 that a controller spec would never see.
it "renders the show page" do
get order_path(order)
expect(response).to have_http_status(:ok)
expect(response.body).to include(order.sku)
end
Keep assertions on the body shallow. Deep HTML structure assertions belong in system specs; here you’re proving the template renders without error and contains the key data.
Shared Examples for Repeated Boundaries
Authorization checks repeat across every endpoint, and copy-pasting them is how they go stale.
Example:
# spec/support/shared_examples/authenticated_endpoint.rb
RSpec.shared_examples "an authenticated endpoint" do |method, path_helper|
it "returns 401 without credentials" do
public_send(method, public_send(path_helper))
expect(response).to have_http_status(:unauthorized)
end
end
RSpec.shared_examples "a tenant-scoped endpoint" do |method, path_builder|
it "returns 404 for a record belonging to another account" do
foreign = create(:order)
public_send(method, path_builder.call(foreign), headers: valid_headers)
expect(response).to have_http_status(:not_found)
end
end
RSpec.describe "Orders", type: :request do
it_behaves_like "an authenticated endpoint", :get, :api_orders_path
it_behaves_like "a tenant-scoped endpoint", :get, ->(o) { "/api/orders/#{o.id}" }
end
One line per endpoint, and adding a new controller without the authorization check becomes a visible omission in review.
Pro-Tip: Write a request spec that asserts every route in your application requires authentication, generated from
Rails.application.routes.routesrather than listed by hand. Iterate the route set, skip an explicit allowlist of genuinely public paths, and assert each one returns 401 or redirects to sign-in when called without credentials. The value isn’t the endpoints you know about — it’s the controller someone adds in eight months who forgets thebefore_action, which no hand-written spec will ever cover because nobody remembers to add one. The allowlist makes the exceptions explicit and reviewable: adding a path to it is a visible line in a diff that a reviewer will question, whereas a missingbefore_actionis invisible. This is about twenty lines of spec and it’s the highest-value test in most Rails codebases.
Migrating Away From Controller Specs
If you have a pile of them, the conversion is mechanical:
# Before
get :show, params: { id: order.id }
expect(assigns(:order)).to eq(order)
expect(response).to render_template(:show)
# After
get order_path(order)
expect(response).to have_http_status(:ok)
expect(response.body).to include(order.sku)
The mapping:
get :action, params:→get path_helper(...)assigns(:x)→ assert on the response body or the databaserender_template→ assert on rendered content, or drop itcontroller.stub(...)→ set up real data instead
That last one is the sticking point. Controller specs encourage stubbing the controller’s collaborators; request specs push you toward real objects. That’s more setup and a better test — the stub was hiding whether the pieces fit together.
Don’t convert everything at once. Convert as you touch each controller, and require request specs for anything new.
Conclusion
Use request specs as the default for the controller layer: they cover routing and middleware, they render views, and they assert on what a client actually receives rather than on instance variable names. Spend them on status codes, response contracts, and authorization boundaries — especially the 404-for-another-tenant case — and push business rules down into model specs where they run faster and read better. Skip controller specs in new code, convert the old ones opportunistically, and add the generated all-routes-require-auth spec, because it catches the endpoint nobody wrote a test for.
FAQs
Q1: Are controller specs deprecated?
Not removed, but discouraged since Rails 5 — assigns and assert_template were extracted to rails-controller-testing. The official guidance is request specs, and new Rails apps don’t generate controller specs.
Q2: How do I test a before_action in isolation?
Through the endpoints it protects. If a filter has enough logic to warrant its own test, extract it into a class or a service object and unit-test that.
Q3: Are request specs slow?
Much faster than system specs — no browser — and only marginally slower than controller specs. The difference is small enough that it should not influence the decision.
Q4: How do I authenticate in a request spec?
Set the header for token auth, or post to the sign-in path once in a helper for session auth. Avoid stubbing current_user — that skips the authentication code you want covered.
Q5: Should I test both HTML and JSON formats for the same action?
If both are supported and used, yes — they take different code paths through rendering and error handling. as: :json and an explicit Accept header keep them separate.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀