Four Key Reasons to Learn Markdown
Back-End Leveling UpWriting documentation is fun—really, really fun. I know some engineers may disagree with me, but as a technical writer, creating quality documentation that will...
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 PUT
s should
return the same status code; all unsuccessful POST
s 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.
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
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 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.
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
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.
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.
Writing documentation is fun—really, really fun. I know some engineers may disagree with me, but as a technical writer, creating quality documentation that will...
Humanity has come a long way in its technological journey. We have reached the cusp of an age in which the concepts we have...
Go 1.18 has finally landed, and with it comes its own flavor of generics. In a previous post, we went over the accepted proposal and dove...