RSpec Macros

Nobody likes writing boilerplate. It’s repetitive, slow, and just plain boring. One of the first rules new programmers learn is not to repeat themselves. We do a good job upholding this principle in our app code, but unit tests are a different story. In unit tests, it’s often preferable not to write DRY code. We prefer explicitness and clarity to malleability and reusability.

Usually.

As with all rules in software development, I believe there are exceptions to this rule. Consider authentication. For every route in your app that requires authentication, you need to write a controller test to assert that an unauthenticated user does not have access. I write those tests something like this (as described here).

RSpec.describe SecretsController do

  describe "GET index" do
    it "requires the user be signed in" do
      get :index
      expect(response).to redirect_to new_session_path
    end
  end
end

I’ve written controller tests exactly like this at least a hundred times over the past couple of years, and you know what I realized? It’s just boilerplate. It took me a long time to even consider those repetitive tests duplication (they’re testing different application code, after all), but there comes a point when the repetition becomes hard to ignore.

I wanted a way to simply declare that my controller action should require the user to be authenticated, rather than imperatively (and incessantly) writing out that same unit test again.

I want to write code like this:

RSpec.describe SecretsController do

  describe "GET index" do
    it_requires_the_user_be_signed_in
  end
end

We could accomplish something similar with shared examples, but the language isn’t as expressive as I want. Could we define the method we want ourselves?

Parsing the HTTP method and controller action

Our method will need to send the right HTTP request to the right endpoint, then assert that the controller sent us to the sign in page. Due to my consistent organization of my controller specs, by the time we call it_requires_the_user_be_signed_in, we’ve already written the name of the controller, the HTTP method and the controller action in our descriptions.

We can get this information out of RSpec’s current_example method within an example:

RSpec.describe SomeClass do
  describe "some description" do
    it "can introspect its own metadata" do
      expect(RSpec.current_example.metadata[:example_group][:description])
        .to eq "some description"
    end
 end
end

A simple macro

RSpec also makes it easy to make extra methods available within an example. But in this case, we want the method it_requires_the_user_be_signed_in to be available outside the example blocks. The only way I know to accompish this is to define the method globally. For organizational purposes, I like to define a module and include that module into the global namespace in the appropriate *_helper.rb file:

module Macros
  def it_requires_the_user_be_signed_in
    it "requires the user be signed in" do
      http_method, controller_action = (
        RSpec.current_example.metadata[:example_group][:description]
          .downcase.split.map(&:to_sym)
      )

      public_send(http_method, controller_action)

      expect(response).to redirect_to new_session_path
    end
  end
end
# ...

Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

RSpec.configure do |config|
  # ...
end

include Macros

With that in place, we’ve implemented the interface we wanted and we can reuse it anywhere!

require "rails_helper"

RSpec.describe SecretsController do

  describe "GET index" do
    it_requires_the_user_be_signed_in
  end

  describe "GET show" do
    it_requires_the_user_be_signed_in
  end
end

RSpec.describe OtherController do

  describe "GET new" do
    it_requires_the_user_be_signed_in
  end

  describe "POST create" do
    it_requires_the_user_be_signed_in
  end
end

It will automatically parse out the right HTTP method and controller action to invoke, and then run the right assertion.

Caveats

So far, controller authentication is the only case where I’ve actually used this idea, but I’m going to keep an eye out for more opportunities. It’s definitely a technique to use sparingly, since it sacrifices clarity for convenience. But in this case, it’s helping me move faster through the boilerplate so I can get back to the interesting stuff

Categories

Other posts