Better Rails 5 API Controller Tests with RSpec Shared Examples

Now that Rails 5 has officially launched, and we've begun building with it in earnest, I've spent some time taking advantage of the Rails 5 API feature in particular.

So far, I've found functional controller testing in Rails 5 API to be pretty smooth. However, if you're testing a Rails API, it shouldn't be difficult to notice lots of repetition. After all, the behavior across controller actions for various resources should be pretty similar.

In this post, we'll implement RSpec Shared Examples to cut down on some of this repetition and keep our code nice and DRY.

First, let's set up our app for testing.

Background

The API we're testing is a very simple Rails 5 API that serves data on users, cats and the hobbies associated with our cats (yes, cat's have hobbies).


This cat enjoys cooking, for example. Unrelated: you should play this game.

Our API uses Active Model Serializers to serialize data in JSON API format, with the help of the JSON API adapter. Our API uses a JSON Web Token (JWT) authentication system. Authenticated requests should have an Authorization header of Bearer <valid JWT>. To learn more about the background of this app, you can check out these earlier posts.

Okay, let's set up our tests!

Setting Up RSpec for Functional Controller Tests

What's a functional controller test? Let's ask Rails!

In Rails, testing the various actions of a controller is a form of writing functional tests. Remember your controllers handle the incoming web requests to your application...When writing functional tests, you are testing how your actions handle the requests and the expected result or response...
–– Rails Guides

We'll assume that we've already bundle installed our rspec-rails gem and run rails g rspec:install.

Including Rack::Test::Methods with a Custom Helper

Our functional controller tests will definitely need to use Rack Test Methods like get, post, header, etc. So, let's build a custom helper to include these methods in our API controller tests.

Create a folder, spec/support, and a file spec/support/api_helper.rb

Here, we'll define our custom helper to include Rack::Test::Methods. Soon, we'll tell our RSpec config block to apply this module only for those tests that are scoped to our API.

module ApiHelper
  include Rack::Test::Methods

  def app
    Rails.application
  end
end

Thanks to this gist, authored by Alex Z. Li for this helper.

Cutting Repetition with a Custom JSON Parser Helper

Another area of repetition we'll encounter as we build out our controller tests lies in JSON parsing. Our API serves JSON, so all of the responses our tests will be operating on will need to be JSON.parse-ed. Something like:

# some controller test

it "returns a single cat" do
  get '/cats/1'
  json = JSON.parse(last_response.body)
  expect(json["data"]["id"]).to eq(1) 
end

Over and over again. So, let's build a custom helper to abstract away all of that soon-to-be repetitious JSON parsing.

# spec/support/request_helper.rb

module Requests
  module JsonHelpers
    def json
      JSON.parse(last_response.body)
    end
  end
end

With the help of this helper, we can skip the explicit call to JSON.parse to each individual test, and instead refer to the parsed response via the method call, json.

Including Our Helpers

Don't forget to actually include your helpers in your test environment!

In our rails_helper, we need to require the files from our spec/support directory.

# rails_helper

ENV["RAILS_ENV"] ||= 'test'
require 'spec_helper'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}

...

In our spec_helper, we need to configure RSpec to apply our custom helper modules only to those tests that are scoped to our API.

require 'rails_helper'
require 'rspec-rails'

DatabaseCleaner.strategy = :truncation

RSpec.configure do |config|
  config.include ApiHelper, type: :api
  config.include Requests::JsonHelpers, type: :api
  ...
end

Great! We're ready to write some API controller tests.

Identifying Repetition in Controller Tests

So, consider the desired behavior in the various cases in which the client sends a request for a resource which doesn't exist.

Let's say a user attempts to visit the following route /cats/7/hobbies, but the cat with an ID of 7 doesn't exist.
We'd want our API to respond with a 404 status, and a message of "Not found".

What about when a user sends a request for /hobbies/15, but the hobby with an ID of 15 doesn't exist? We'd once again want our API to respond with a 404 status, and a message of "Not found". Let's take a look at this repetitious behavior.

#spec/controllers/api/v1/users_controller_spec.rb

require "spec_helper"
require 'jwt'

describe Api::V1::Cats::HobbiesController , :type => :api do
  context 'when the cat does not exist'
    
    before do 
     token = JWT.encode({user: User.first.id}, 
        ENV["AUTH_SECRET"], "HS256")
      header "Authorization", "Bearer #{token}"
      get "/cats/-1/hobbies"
    end
    
    it 'responds with a 404 status' do 
      expect(last_response.status).to eq 404
    end

    it 'responds with a message of Not found' do
      message = json["errors"].first["detail"] 
      expect(message).to eq("Not found")
    end
  end
end
#spec/controllers/api/v1/users_controller_spec.rb

require "spec_helper"
require 'jwt'

describe Api::V1::HobbiesController , :type => :api do
  context 'when the hobby does not exist'
    
    before do 
     token = JWT.encode({user: User.first.id}, 
        ENV["AUTH_SECRET"], "HS256")
      header "Authorization", "Bearer #{token}"
      get "/hobbies/-1"
    end
    
    it 'responds with a 404 status' do 
      expect(last_response.status).to eq 404
    end

    it 'responds with a message of Not found' do 
      message = json["errors"].first["detail"] 
      expect(message).to eq("Not found")
    end
  end
end

These two tests are almost identical, save for the different web requests they are making, one to cats/-1/hobbies and one to hobbies/-1. Wouldn't it be great if we could abstract away this repetition, while still dynamically specifying the URL of the web request for each test? Lucky for us, there's RSpec Shared Examples!

Implementing Shared Examples

So, what are Shared Examples?

Shared examples let you describe behaviour of classes or modules. When declared, a shared group's content is stored. It is only realized in the context of another example group, which provides any context the shared group needs to
run.*

In other words, with shared examples, we can wrap up shared expectations and call on them in the context of any number of different tests. We can call on our shared examples with any of the following methods:

# include the examples in the current context
include_examples "name" 
     
# include the examples in a nested context
it_behaves_like "name"      

# include the examples in a nested context
it_should_behave_like "name"

# include the examples in the current context 
matching metadata            

Now that we have a basic understanding of what shared examples are and what they can do for us, let's define our very own shared example.

Defining a Shared Examples

Create a folder spec/controllers/api/v1/shared_examples. Here, we'll create a file, respond_to_missing.rb.

RSpec.shared_examples 'respond to missing' do |url|
  before do 
    token = JWT.encode({user: User.first.id}, 
      ENV["AUTH_SECRET"], "HS256")
    header "Authorization", "Bearer #{token}"
    get url
  end
  
  it 'responds with 404' do
    expect(last_response.status).to eq 404
  end

  it 'responds with error' do
    message = json["errors"].first["detail"] 
    expect(message).to eq("Not found")
  end
end

We've defined our shared example to yield a parameter, which we've called url, to the block that contains our formerly repetitious code--the before block that sets the request header and makes the web request to the appropriate URL, and the two it blocks that test our response status and content.

Now we're ready to call on our shared example in our two tests.
Remember to include the file that contains our shared example in each of your test files that will use the shared example.

#spec/controllers/api/v1/users_controller_spec.rb

require 'spec_helper'
require 'jwt'
require Rails.root.join('spec', 'controllers', 'api', 'v1',     
  'shared_examples', 'resond_to_missing.rb')

describe Api::V1::Cats::HobbiesController , :type => :api do
  context 'when the cat does not exist'
    subject { controller }
    it_behaves_like "respond to missing", '/cats/-1/hobbies'
  end
end
#spec/controllers/api/v1/users_controller_spec.rb

require "spec_helper"
require 'jwt'

describe Api::V1::HobbiesController , :type => :api do
  context 'when the hobby does not exist'
    subject { controller }
    it_behaves_like "respond to missing", '/hobbies/-1'
  end
end

Much better!

Conclusion

This has a been a brief look at using shared examples to cut down on repetitious functional controller tests. Shared examples can be applied to any number of smelly controller tests to keep them clean and DRY. Happy coding!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus