Search code examples
ruby-on-railsrubyrspecrspec-rails

Rails RSpec, DRY specs: shared example vs. helper method vs. custom matcher


I have the following test repeated once for each HTTP method/controller action combination within a controller spec:

it "requires authentication" do
  get :show, id: project.id
  # Unauthenticated users should be redirected to the login page
  expect(response).to redirect_to new_user_session_path
end

I've found the three following ways to refactor it and eliminate repetition. Which one is the most appropriate?

Shared Example

It seems to me that shared examples are the most appropriate solution. However, having to use a block in order to pass the params to the shared example feels a bit awkward.

shared_examples "requires authentication" do |http_method, action|
  it "requires authentication" do
    process(action, http_method.to_s, params)
    expect(response).to redirect_to new_user_session_path
  end
end

RSpec.describe ProjectsController, type: :controller do
  describe "GET show", :focus do
    let(:project) { Project.create(name: "Project Rigpa") }

    include_examples "requires authentication", :GET, :show do
      let(:params) { {id: project.id} }
    end
  end
end

Helper Method

This has the advantage of not requiring a block to pass project.id to the helper method.

RSpec.describe ProjectsController, type: :controller do
  def require_authentication(http_method, action, params)
    process(action, http_method.to_s, params)
    expect(response).to redirect_to new_user_session_path
  end

  describe "GET show", :focus do
    let(:project) { Project.create(name: "Project Rigpa") }

    it "requires authentication" do
      require_authentication(:GET, :show, id: project.id )
    end
  end
end

Custom Matcher

It would be nice to have a single-line test.

RSpec::Matchers.define :require_authentication do |http_method, action, params|
  match do
    process(action, http_method.to_s, params)
    expect(response).to redirect_to Rails.application.routes.url_helpers.new_user_session_path
  end
end

RSpec.describe ProjectsController, type: :controller do
  describe "GET show", :focus do
    let(:project) { Project.create(name: "Project Rigpa") }

    it { is_expected.to require_authentication(:GET, :show, {id: project.id}) }
  end
end

Thanks in advance.


Solution

  • A suggestion provided by didroe in this Reddit post got me thinking that placing the method/action call (process) within shared code is not a good idea as it increases complexity (reduces readability) and does not actually reduce code duplication.

    After searching some more, I have found what I believe to be best option in the Everyday Rails Testing with RSpec by Aaron Sumner book (p. 102).

    Create the following custom matcher:

    # spec/support/matchers/require_login.rb
    RSpec::Matchers.define :require_login do |expected|
      match do |actual|
        expect(actual).to redirect_to \
          Rails.application.routes.url_helpers.new_user_session_path
      end
    
      failure_message do |actual|
        "expected to require login to access the method"
      end
    
      failure_message_when_negated do |actual|
        "expected not to require login to access the method"
      end
    
      description do
        "redirect to the login form"
      end
    end
    

    And use a test like the following for each action of each controller:

    it "requires authentication" do
      get :show, id: project.id
      expect(response).to require_login
    end
    

    Compared to repeating expect(response).to redirect_to new_user_session_path in all tests, this approach has the following advantages:

    • Improved maintanability. If we eventually have to change this assertion, we change it in one place instead of having to change dozens or hundreds of tests.
    • Better failure messages.

    What do you think?