How to test a Controller Concern in Rails 4
Asked Answered
T

4

48

What is the best way to handle testing of concerns when used in Rails 4 controllers? Say I have a trivial concern Citations.

module Citations
    extend ActiveSupport::Concern
    def citations ; end
end

The expected behavior under test is that any controller which includes this concern would get this citations endpoint.

class ConversationController < ActionController::Base
    include Citations
end

Simple.

ConversationController.new.respond_to? :yelling #=> true

But what is the right way to test this concern in isolation?

class CitationConcernController < ActionController::Base
    include Citations
end

describe CitationConcernController, type: :controller do
    it 'should add the citations endpoint' do
        get :citations
        expect(response).to be_successful
    end
end

Unfortunately, this fails.

CitationConcernController
  should add the citations endpoint (FAILED - 1)

Failures:

  1) CitationConcernController should add the citations endpoint
     Failure/Error: get :citations
     ActionController::UrlGenerationError:
       No route matches {:controller=>"citation_concern", :action=>"citations"}
     # ./controller_concern_spec.rb:14:in `block (2 levels) in <top (required)>'

This is a contrived example. In my app, I get a different error.

RuntimeError:
  @routes is nil: make sure you set it in your test's setup method.
Tucket answered 26/2, 2014 at 23:34 Comment(0)
A
97

You will find many advice telling you to use shared examples and run them in the scope of your included controllers.

I personally find it over-killing and prefer to perform unit testing in isolation, then use integration testing to confirm the behavior of my controllers.

Method 1: without routing or response testing

Create a fake controller and test its methods:

describe MyControllerConcern do
  before do
    class FakesController < ApplicationController
      include MyControllerConcern
    end
  end

  after do
    Object.send :remove_const, :FakesController 
  end

  let(:object) { FakesController.new }

  it 'my_method_to_test' do
    expect(object).to eq('expected result')
  end

end

Method 2: testing response

When your concern contains routing or you need to test for response, rendering etc... you need to run your test with an anonymous controller. This allow you to gain access to all controller-related rspec methods and helpers:

describe MyControllerConcern, type: :controller do
  controller(ApplicationController) do
    include MyControllerConcern

    def fake_action; redirect_to '/an_url'; end
  end

  before do
    routes.draw {
      get 'fake_action' => 'anonymous#fake_action'
    }
  end
    
  describe 'my_method_to_test' do
    before do
      get :fake_action 
    end

    it do
      expect(response).to redirect_to('/an_url') 
    end
  end
end

As you can see, we define the anonymous controller with controller(ApplicationController). If your test concerne another class than ApplicationController, you will need to adapt this.

Also for this to work properly you must configure the following in your spec_helper.rb file:

config.infer_base_class_for_anonymous_controllers = true

Note: keep testing that your concern is included

It is also important to test that your concern class is included in your target classes, one line suffice:

describe SomeTargetedController do
  it 'includes MyControllerConcern' do
    expect(SomeTargetedController.ancestors.include? MyControllerConcern).to be(true) 
  end
end
Accounting answered 27/2, 2014 at 3:49 Comment(5)
This test can be definitely important! As it test the concern in isolation, but if you share these between 3 controllers and you dont have a shared example. If somehow somebody takes the include MyControllerConcern from one your controllers the tests wont fail and the mistake will pass unnoticeable... so even though you do the isolation test you will still need the shared examples to make sure your controllers do what they are supposed to do. In that case making this test is an overkill, because you should already have the shared example...Retorsion
That's why you cover with integration. The topic here was unit testing. And you are right: it's a good idea to unit test the include of the Concern module: just testing for ancestor should be enough.Accounting
FYI: Object#remove_const is private, that's why we have to use sendSubtlety
can I set params in the FakesController of the 1st method?Quasijudicial
@Quasijudicial you can: with object.params = { year: '2012' }Jody
P
29

Simplifying on method 2 from the most voted answer.

I prefer the anonymous controller supported in rspec http://www.relishapp.com/rspec/rspec-rails/docs/controller-specs/anonymous-controller

You will do:

describe ApplicationController, type: :controller do
  controller do
    include MyControllerConcern

    def index; end
  end

  describe 'GET index' do
    it 'will work' do
      get :index
    end
  end
end

Note that you need to describe the ApplicationController and set the type in case this does not happen by default.

Printer answered 5/6, 2015 at 13:59 Comment(5)
That is the method I describe in "Method 2" in my answer.Accounting
@BenjaminSinclaire it has a little twist to it, routes.draw is not actually required and I found it a little confusing when trying out your answer, and a link to the source is a nice addition.Printer
NB, anonymous controller only defines resourceful routes by default, if you want custom actions, you will still have to call routes.draw via relishapp.com/rspec/rspec-rails/docs/controller-specs/…Howie
I have searched for a while for a way to test routes added by concerns. is this doable as far as you know? If not, I will not bother asking a questionKafka
Should be, I don't see why, please ask the question and will try to provide you with an answer.Printer
R
5

My answer may look bit more complicated than these by @Benj and @Calin, but it has its advantages.

describe Concerns::MyConcern, type: :controller do

  described_class.tap do |mod|
    controller(ActionController::Base) { include mod }
  end

  # your tests go here
end

First of all, I recommend the use of anonymous controller which is a subclass of ActionController::Base, not ApplicationController neither any other base controller defined in your application. This way you're able to test the concern in isolation from any of your controllers. If you expect some methods to be defined in a base controller, just stub them.

Furthermore, it is a good idea to avoid re-typing concern module name as it helps to avoid copy-paste errors. Unfortunately, described_class is not accessible in a block passed to controller(ActionController::Base), so I use #tap method to create another binding which stores described_class in a local variable. This is especially important when working with versioned APIs. In such case it is quite common to copy large volume of controllers when creating a new version, and it's terribly easy to make such a subtle copy-paste mistake then.

Rembert answered 29/7, 2016 at 21:44 Comment(0)
C
-1

I am using a simpler way to test my controller concerns, not sure if this is the correct way but seemed much simpler that the above and makes sense to me, its kind of using the scope of your included controllers. Please let me know if there are any issues with this method. sample controller:

class MyController < BaseController
  include MyConcern

  def index
    ...

    type = column_type(column_name)
    ...
  end

end

my controller concern:

module MyConcern
  ...
  def column_type(name)
    return :phone if (column =~ /phone/).present?
    return :id if column == 'id' || (column =~ /_id/).present?
   :default
  end
  ...

end

spec test for concern:

require 'spec_helper'

describe SearchFilter do
  let(:ac)    { MyController.new }
  context '#column_type' do
    it 'should return :phone for phone type column' do
      expect(ac.column_type('phone')).to eq(:phone)
    end

    it 'should return :id for id column' do
      expect(ac.column_type('company_id')).to eq(:id)
    end

    it 'should return :id for id column' do
      expect(ac.column_type('id')).to eq(:id)
    end

    it 'should return :default for other types of columns' do
      expect(ac.column_type('company_name')).to eq(:default)
    end
  end
end
Contrayerva answered 26/4, 2017 at 20:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.