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 Authenticate
s 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 require
d and include
d 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!
Top comments (1)
An excellent writeup, Ayush!
Testing mixins in isolation, as if we were writing them in a gem without any real implementers yet, is key and should be applied irrespective of the number of places used, as I expound in dev.to/epigene/the-alternative-to-...
One avenue of improvement, especially in RSpec context, would be to avoid defining a named class with
class AuthenticateTestsController < TestController
which pollutes the object space and can lead to subtle bugs due to collision, and instead define an anonymous class and usestub_const
to attach the class to the constant for just that one example.