fbpx

Blogs from the Ranch

< Back to Our Blog

Using RSpec Shared Contexts to Ensure API Consistency

I recently had the opportunity to work on a fairly large
service-oriented application. One of my key responsibilities on the
team was to ensure that the main database application was well-tested. This project taught me the importance of a consistent API. Here I’ll share one tool I learned for ensuring that consistency.

Our team settled on a testing plan that involved writing tests for each API
endpoint, with the thought being that if we knew exactly how each of those
worked, then it should be easier to work with them in client apps (and
harder to break other client apps when making changes to
the database app).

One of the main things I learned while working on this project is how
important a consistent API design is. All successful PUTs should
return the same status code; all unsuccessful POSTs should return, e.g., JSON with the same structure. But it is incredibly easy to lose sight of this
when you are actually writing the API. When writing the
CommentsController#create action, I almost always had to peek at the
PostsController#create action just to make sure I was keeping
things consistent. This was tedious and error prone. Here is the method I used to ensure consistency in the APIs.

Sample controller code

Here are a pair of controllers we can use to drive discussion:

  # app/controllers/comments_controller.rb
  class CommentsController < ApplicationController
    def show
      render json: Hash[comment: Hash[id: params[:id]]]
    end

    def create
      render json: Hash[errors: {error: 'reason'}], status: 422
    end
  end

  # app/controllers/posts_controller.rb
  class PostsController < ApplicationController
    def show
      render json: Hash[id: params[:id]]
    end

    def create
      render json: Hash[error: 'reason']
    end
  end

A naive approach to testing

Here’s how I tested these four actions at the start of the
project:

  # spec/requests/comments_controller_spec.rb
  describe CommentsController do
    describe '#show' do
      let(:id) { '1' }
      let!(:json) {
        get comment_path(id)
        JSON.parse(response.body)['comment']
      }

      it 'returns the specified item' do
        expect(json['id']).to eq(id)
      end

      it 'responds with a 200 status' do
        expect(response.status).to eq(200)
      end
    end

    describe '#create' do
      let(:message) { 'reason' }
      let!(:json) {
        post comments_path
        JSON.parse(response.body)['errors']
      }

      it 'returns the error message' do
        expect(json['error']).to eq(message)
      end

      it 'responds with a 422 status' do
        expect(response.status).to eq(422)
      end
    end
  end

  # spec/requests/posts_controller_spec.rb
  describe PostsController do
    describe '#show' do
      let(:id) { '1' }
      let!(:json) {
        get post_path(id)
        JSON.parse(response.body)
      }

      it 'returns the specified item' do
        expect(json['id']).to eq(id)
      end

      it 'responds with a 200 status' do
        expect(response.status).to eq(200)
      end
    end

    describe '#create' do
      let(:message) { 'reason' }
      let!(:json) {
        post posts_path
        JSON.parse(response.body)
      }

      it 'returns the error message' do
        expect(json['error']).to eq(message)
      end

      it 'responds with a 200 status' do
        expect(response.status).to eq(200)
      end
    end
  end

These tests seem okay. They aren’t obviously wrong, at least. But there
is an error here that, I thought, was subtle. It’s easier to see when
we look at the results of bin/rspec -f documentation

  $ bin/rspec -f documentation

  CommentsController
    #show
      returns the specified item
      responds with a 200 status
    #create
      returns the error message
      responds with a 422 status

  PostsController
    #show
      returns the specified item
      responds with a 200 status
    #create
      returns the error message
      responds with a 200 status

My test descriptions are the same for all but one case (the status
code for the #create action). These test descriptions, at least
when I’m writing them in the moment, feel perfectly accurate and
complete to me. Yet they paper over huge inconsistencies in my
API.

Shared contexts to the rescue

Shared contexts are groups of
examples that you can call from within multiple describe blocks.
They should live in an appropriately named file in spec/support.
Note that they should not be named something_or_other_spec.rb;
rather, that should be something_or_other.rb. If nothing else,
shared contexts are going to highlight the inconsistencies in my API. Here are ‘shared contexts’ that capture what is going on in my specs.

  # spec/support/shared_api_contexts.rb
  shared_context 'a failed create' do
    it 'returns an unprocessable entity (422) status code' do
      expect(response.status).to eq(422)
    end
  end

  shared_context 'a response with nested errors' do
    it 'returns the error messages' do
      json = JSON.parse(response.body)['errors']
      expect(json['error']).to eq(message)
    end
  end

  shared_context 'a response with errors' do
    it 'returns the error messages' do
      json = JSON.parse(response.body)
      expect(json['error']).to eq(message)
    end
  end

  shared_context 'a show request with a root' do |root|
    it 'returns the specified item' do
      json = JSON.parse(response.body)[root]
      expect(json['id']).to eq(id)
    end
  end

  shared_context 'a show request' do
    it 'returns the specified item' do
      json = JSON.parse(response.body)
      expect(json['id']).to eq(id)
    end
  end

  shared_context 'a successful request' do
    it 'returns an OK (200) status code' do
      expect(response.status).to eq(200)
    end
  end

And here are specs that put these shared contexts to use:

  # spec/requests/comments_controller_spec.rb
  describe CommentsController do
    describe '#show' do
      before do
        get comment_path(id)
      end
      let(:id) { '1' }

      it_behaves_like 'a show request with a root', 'comment'
      it_behaves_like 'a successful request'
    end

    describe '#create' do
      before do
        post comments_path
      end
      let(:message) { 'reason' }

      it_behaves_like 'a response with nested errors'
      it_behaves_like 'a failed create'
    end
  end

  # spec/requests/posts_controller_spec.rb
  describe PostsController do
    describe '#show' do
      before do
        get post_path(id)
      end
      let(:id) { '1' }

      it_behaves_like 'a show request'
      it_behaves_like 'a successful request'
    end

    describe '#create' do
      before do
        post posts_path
      end
      let(:message) { 'reason' }

      it_behaves_like 'a response with errors'
      it_behaves_like 'a successful request'
    end
  end

The difference between the behavior is now pretty obvious. And it
becomes even more obvious when we check out the RSpec test output:

  $ bin/rspec -f documentation

  CommentsController
    #show
      behaves like a show request with a root
        returns the specified item
      behaves like a successful request
        returns an OK (200) status code
    #create
      behaves like a response with nested errors
        returns the error messages
      behaves like a failed create
        returns an unprocessable entity (422) status code

  PostsController
    #show
      behaves like a show request
        returns the specified item
      behaves like a successful request
        returns an OK (200) status code
    #create
      behaves like a response with errors
        returns the error messages
      behaves like a successful request
        returns an OK (200) status code

It’s clear here, for example, that PostsController#show and
CommentsController#show return items with a different shape, as it
were: the latter has a root element where the former does not. This
is very good information, even if we weren’t able to fix it now. But
since this is easy code and I have the time, let’s take a look at what
this API (and these tests) would look like if I could make all the
changes I wanted.

Bringing the API in line with cleaner shared contexts

If I want to enforce consistency, it seems to me that the simplest
thing to do is have a context for each branch of each controller
action. Then I can know that all my #index actions are parallel,
for example. I could write smaller, more reusable components, but my
hunch is that mixing and matching is going to be more trouble than it
is worth. So here are the tests I want:

  # spec/support/shared_api_contexts.rb
  shared_context 'a failed create' do
    it 'returns an unprocessable entity (422) status code' do
      expect(response.status).to eq(422)
    end

    it 'returns the error messages' do
      json = JSON.parse(response.body)['errors']
      expect(json['error']).to eq(message)
    end
  end

  shared_context 'a successful show request' do |root|
    it 'returns an OK (200) status code' do
      expect(response.status).to eq(200)
    end

    it 'returns the specified item' do
      json = JSON.parse(response.body)[root]
      expect(json['id']).to eq(id)
    end
  end

  # spec/requests/comments_controller_spec.rb
  describe CommentsController do
    describe '#show' do
      before do
        get comment_path(id)
      end
      let(:id) { '1' }

      it_behaves_like 'a successful show request', 'comment'
    end

    describe '#create' do
      before do
        post comments_path
      end
      let(:message) { 'reason' }

      it_behaves_like 'a failed create'
    end
  end

  # spec/requests/posts_controller_spec.rb
  describe PostsController do
    describe '#show' do
      before do
        get post_path(id)
      end
      let(:id) { '1' }

      it_behaves_like 'a successful show request', 'post'
    end

    describe '#create' do
      before do
        post posts_path
      end
      let(:message) { 'reason' }

      it_behaves_like 'a failed create'
    end
  end

Of course, these tests fail at the moment. Here is the updated code:

  # app/controllers/comments_controller.rb
  class CommentsController < ApplicationController
    def show
      render json: Hash[comment: Hash[id: params[:id]]]
    end

    def create
      render json: Hash[errors: {error: 'reason'}], status: 422
    end
  end

  # app/controllers/posts_controller.rb
  class PostsController < ApplicationController
    def show
      render json: Hash[post: Hash[id: params[:id]]]
    end

    def create
      render json: Hash[errors: Hash[error: 'reason']], status: 422
    end
  end

Now when I run my tests I see that all the endpoints are consistent:

  $ bin/rspec -f documentation

  CommentsController
    #create
      behaves like a failed create
        returns the error messages
        returns an unprocessable entity (422) status code
    #show
      behaves like a successful show request
        returns the specified item
        returns an OK (200) status code

  PostsController
    #create
      behaves like a failed create
        returns the error messages
        returns an unprocessable entity (422) status code
    #show
      behaves like a successful show request
        returns the specified item
        returns an OK (200) status code

Drawbacks of shared contexts

It would be unfair to sing the praises of shared contexts without mentioning some of the weaknesses we found. Shared contexts are easy to misuse (using them in cases where the context isn’t really similar is a mistake I made at one point).

But for our team, the most frustrating drawback was the way that shared contexts mess up your spec line numbering. If you have shared contexts in a spec file, you can’t run your specs by line number. bin/rspec spec/controllers/posts_controller:10 will not run the spec on line 10. I’m not entirely sure what is going on, but thanks to the shared contexts you are using, your spec line numbers aren’t what you think. So who knows what spec is actually on line 10. You can work around this by using :focus tags, but that is a new workflow for some of us, and does take getting used to.

The second major drawback is related to the first: when an example in a shared context fails, at least as of RSpec 2, the stack trace points you to the shared context file, not the line/file where the context is included. This means that finding a failing test requires more than finding the line in a file: you have to actually read the test description and find the spec that way. Again, not a huge problem, but certainly a workflow interruption that is worth considering.

Don’t lose sight of consistency

There are many virtues of an API. Consistency is clearly one, and it
is one that it is remarkably easy to lose sight of.

If you are already in the weeds on an inconsistent API, shared
examples can at least highlight your inconsistencies. Maybe those inconsistencies
aren’t so bad (maybe you always follow one of two patterns, say). Or
maybe they are much worse than you thought. Either way, it’s better to know
now and start working toward consistency.

If you are just starting an API project of any size, you can make
consistency in your endpoints easier by deciding up front how
different resourceful actions should behave, writing shared examples
for those cases, and then sticking with them. Every now and then,
abandon your dull test runner for the more verbose -f documentation
runner and make sure things are still looking right.

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project