/ Tags: RAILS / Categories: RAILS

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 no JSON.parse boilerplate
  • as: :json sets 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.routes rather 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 the before_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 missing before_action is 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 database
  • render_template → assert on rendered content, or drop it
  • controller.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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More