Testing controller concerns in Rails

posted by Ayush Newatia
28 October, 2022



This post was extracted and adapted from The Rails and Hotwire Codex.

Concerns are a great way to organize and de-duplicate code in Rails.

One of my favorite use cases is to abstract everything out of the ApplicationController into concerns. This begs the question: how do you test these concerns?

The approach for testing concerns is a bit of a judgement call as they’re always mixed in with a class and aren’t used on their own. Ideally we’d write tests to verify the behavior of a class which would also test the concern in the bargain.

But what if the concern adds the same functionality to a large number of classes? For example, a concern which Authenticates every request. It isn’t pragmatic to test each and every controller action for authentication logic. In such cases, I believe the best approach is to test the concern in isolation.

Test harness for controller concerns

Let’s stick with the hypothetical example of a concern called Authenticate. It’s usable only within a controller, so we’ll need a test harness to test it independently.

Create a support/ folder under test/ and create some files for the test harness within it.

$ mkdir test/support
$ touch test/support/test_controller.rb
$ touch test/support/routes_helper.rb

The TestController doesn’t need to do much by itself. It will be subclassed in individual tests. Each action will render the name of the controller and action which can then be asserted in the test cases.

class TestController < ActionController::Base
  def index; end
  def new; end
  def create; end
  def show; end
  def edit; end
  def update; end
  def destroy; end

  private

    def default_render
      render plain: "#{params[:controller]}##{params[:action]}"
    end
end

Next, we need a way to draw some test-only routes to point to TestController subclasses in test cases. The test routes will be scoped to /test so they don’t clash with existing routes.

# test/support/routes_helper.rb

# Ensure you call `reload_routes!` in your test's `teardown`
# to erase all the routes drawn by your test case.

module RoutesHelpers
  def draw_test_routes(&block)
    # Don't clear routes when calling `Rails.application.routes.draw`
    Rails.application.routes.disable_clear_and_finalize = true

    Rails.application.routes.draw do
      scope "test" do
        instance_exec(&block)
      end
    end
  end

  def reload_routes!
    Rails.application.reload_routes!
  end
end

The draw_test_routes helper takes a block which is executed inside the context of Rails.application.routes.draw. In essence, it’s doing the exact same thing as config/routes.rb, but in the context of the test suite.

Files under the test/ folder in Rails are not autoloaded, so these files need to be required and included manually.

# test/test_helper.rb

# ...

Dir[Rails.root.join("test", "support", "**", "*.rb")].each {
  |f| require f
}

# ...

class ActionDispatch::IntegrationTest
  include RoutesHelpers
end

With the test harness in place, we can now write some tests for the Authenticate concern.

$ touch test/controllers/concerns/authenticate_test.rb
require 'test_helper'

class AuthenticateTestsController < TestController
  include Authenticate

  def show
    # ...
  end
end

class AuthenticateTest < ActionDispatch::IntegrationTest
  setup do
    draw_test_routes do
      resource :authenticate_test, only: [:new, :create, :show, :edit]
    end
  end

  teardown do
    reload_routes!
  end

   # ...
   # Test cases go here ...
   # ...
end

The test-specific AuthenticateTestsController strips away any peripheral functionality and enables us to focus on testing the code in Authenticate. It can now be tested just like any other controller!

If you liked this post, check out my book, The Rails and Hotwire Codex, to level-up your Rails and Hotwire skills!