Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 363
BrunoF Avatar asked Jun 16 '17 20:06

BrunoF


2 Answers

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?

like image 163
BrunoF Avatar answered Sep 28 '22 07:09

BrunoF


In the case you describe, I would go for RSpec Custom Matchers. They keep your specs easier to read and closer to the domain of you application. https://relishapp.com/rspec/rspec-expectations/v/2-4/docs/custom-matchers/define-matcher

I would use shared_examples to specify more complex scenarios and call it_behaves_like to check it all at once in different contexts.

You should try to avoid helper methods if possible and only use them in a single file if it helps keep your specs clean.

like image 27
knugie Avatar answered Sep 28 '22 06:09

knugie