DRYing your tests with rspec shared_examples

It is likely that you have an authentication layer behind your Rails’ controllers, for example some actions are only available for logged in users. For this example let’s suppose you let authenticated users create likes and comments on the app.

A simple form of authentication could be to pass a token on the request headers as shown below. There are other options for authentication but we’ll keep it simple for now.

class Api::BaseController < ActionController::Base
  before_action :validate_access_token

  def validate_access_token
    return unauthorized unless authenticate_or_request_with_http_token do |token, _|
      ActiveSupport::SecurityUtils.secure_compare(token, Rails.application.secrets.access_tokens[:api_token])
    end
  end
end

Then the controllers would extend from this base controller, inehriting the authentication part.

class Api::CommentsController < Api::BaseController
  def create
    # creation code
  end
end

class Api::LikesController < Api::BaseController
  def create
    # creation code
  end
end

Using Shared Examples on Rspec

Now you when writing tests for these controllers, you should test the authentication layer. For this I like to use rspec shared_examples where you can reuse the same tests through several spec files. However, when using you have to be disciplined and keep the same name for your variables. In the examples below call_action is the controller action.

All the tests in the shared_examples test all possible scenarios regarding authenticated requests. I personally like to place these files under spec/support. To load these files you can add this to your rails_helper:

Dir[Rails.root.join("spec", "support", "**", "*.rb")].each { |f| require f }
shared_examples_for "an action with a access token authentication" do
  context "with a valid access token" do
    before do
      http_authorization_header(access_token)
      call_action
    end

    it { expect(response.status).to_not eq(401) }
  end

  context "with an invalid access token" do
    before do
      http_authorization_header("invalid_token")
      call_action
    end

    it "returns 401 (unauthorized)" do
      expect(response.status).to eq(401)
    end
  end

  context "without an access token" do
    before do
      http_authorization_header(nil)
      call_action
    end

    it "returns 401 (unauthorized)" do
      expect(response.status).to eq(401)
    end
  end
end

shared_examples_for "an action that does not return an error" do
  it do
    call_action
    expect(response).to_not have_http_status(:error)
  end
end

def http_authorization_header(access_token)
  request.env["HTTP_AUTHORIZATION"] = ActionController::HttpAuthentication::Token.encode_credentials(access_token)
end

So on your controller spec files you would only need to call itbehaveslike and use the name you defined on the shared_examples file.

require 'rails_helper'

describe Api::LikesController, type: :controller do
  let(:access_token) { Rails.application.secrets.access_tokens[:api_token] }
  before { http_authorization_header(access_token) }

  describe "#create" do
    let(:call_action) { post :create, params: params }

   it_behaves_like "an action with a access token authentication"
 end
end

It is not mandatory to keep the sharedexamples in separate files, you can declare them directly on the specfile. I just place them on the support directory because I am reusing it through several controller spec files.

Published 25 Apr 2019

Biomedical engineer, with a keen interest in machine learning currently working as a fullstack developer at Marley Spoon. Ruby, Kotlin, Typescript and Elixir
Ricardo Trindade on Twitter