How to test a controller concern with rspec-rails anonymous controllers

Posted . Visible to the public.

Imagine you have a concern, whose class method declares authorization rules. It installs a before_action that routes each request one of three ways:

  • allowed (renders)
  • denied (redirect to an access-denied page)
  • login (redirect to sign-in)
module AccessGate
  extend ActiveSupport::Concern

  class_methods do
    def permitted_roles(*roles)
      before_action { enforce(roles) }
    end
  end

  private

  def enforce(roles)
    # Extremely simplified
    return if roles.include?(current_role)   # allowed
    current_role == :guest ? redirect_to(SSO.login_url(self)) : redirect_to("/denied")
  end
end

1. Testing strategy

Every controller that uses your concern should only test the things that are important to itself. You want the test coverage for the concern itself to be more exhaustive and to sit in a separate location.

The most common strategy is to have a test/ route with dummy controllers for testing such cases. However, to exercise all behaviors of your concern you might need multiple different controller setups. This can become pretty messy.

The trick: spin up throwaway controllers with rspec-rails' anonymous controller helper, drive real requests, and classify the response.

2. Simplest possible case

Describe the concern as a controller spec. controller(Base) { … } makes a throwaway controller inheriting from Base:

RSpec.describe AccessGate, type: :controller do
  # Throwaway controller
  controller(ApplicationController) do
    # We assume that ApplicationController includes AccessGate. Otherwise, simply include it here.
    permitted_roles :admin
    def index = head(:ok)
  end

  # Temporary routes
  before { routes.draw { get "index" => "anonymous#index" } }

  # Your specs
  it { sign_in_as :admin; get :index; expect(response).to have_http_status(:ok) }
  it { sign_in_as :editor; get :index; expect(response).to redirect_to("/denied") }
end

3. Roadblocks

The anonymous controller(Base) { … } helper handles three things for you:

  • It sets the controller under test for the controller spec. The alternative tests SomeController isn't sufficient here.
  • It isolates routes. A bare routes.draw mutates the global Rails.application.routes. The helper swaps in a fresh RouteSet and restores it afterwards.
  • It names the subclass AnonymousController, so routes read anonymous#index.

Still your job

  • Put actions into the anonymous controller.
  • Draw the routes for them.
  • Stub URLs that depend on the current request. Did you notice the SSO.login_url(self) in our initial example? That would fail. Inbound requests use the temporary route set, but url_for inside the anonymous controller (e.g. a login redirect building a return URL) resolves against the app routes. Your anonymous controller has no routes there, so you will run into an UrlGenerationError. A redirect to a route that exists app-wide (e.g. /denied) is fine; only current-request URLs break.

General advice

Unless your controller is fully app-agnostic, use your real ApplicationController as a base class, not ActionController::Base. Your concern most likely relies on before_actions (and their ordering), helpers, other private methods and rescue_from. Inheriting from ActionController::Base means rebuilding that stack by hand and testing against a reconstruction that can drift.

4. Complete example

require "rails_helper"

RSpec.describe AccessGate, "#permitted_roles", type: :controller do
  shared_context "gated controller" do
    before do
      # Stub URLs that depend on the current request
      allow(SSO).to receive(:login_url).and_return("https://sso.example/login")

      # You can probably share your routes across all specs in this file
      routes.draw { get "index" => "anonymous#index" }
    end

    def sign_in_as(role) = session[:role] = role

    # Define a nice mapping for the results you want to observe
    def outcome
      return :allowed if response.successful?

      case response.location
      when %r{/denied} then :denied
      when %r{/login}  then :login
      else "unexpected: #{response.status} #{response.location}"
      end
    end
  end

  # Assert the outcome for each of your roles
  shared_examples "a gate" do |expectations|
    expectations.each do |role, expected|
      it "#{role} -> #{expected}" do
        sign_in_as(role) unless role == :anonymous
        get :index
        expect(outcome).to eq(expected)
      end
    end
  end

  # Specs for use case 1
  describe "permitted_roles :admin" do
    controller(ApplicationController) do
      permitted_roles :admin
      def index = head(:ok)
    end

    include_context "gated controller"

    it_behaves_like "a gate", { 
      admin: :allowed,
      editor: :denied,
      anonymous: :login
    }
  end

  # Specs for use case 2
  describe "permitted_roles :admin, :editor" do
    controller(ApplicationController) do
      permitted_roles :admin, :editor
      def index = head(:ok)
    end

    include_context "gated controller"

    it_behaves_like "a gate", {
      admin: :allowed,
      editor: :allowed,
      viewer: :denied,
      anonymous: :login
    }
  end
end

You get:

  • One anonymous controller per "concern use case"
  • A shared context for the harness (stubs, routes, the outcome classifier, utilities like the sign_in_as method)
  • A declarative role × expected outcome matrix
Profile picture of Klaus Weidinger
Klaus Weidinger
Last edit
Klaus Weidinger
License
Source code in this card is licensed under the MIT License.
Posted by Klaus Weidinger to makandra dev (2026-07-08 12:15)