We are getting to my favorite part: automated testing! I used to hate it before and then I had to work on a big legacy application, which luckily had tests. I was so happy to be able to refactor it without breaking anything that now, I simply love writing tests for my applications.
Just kidding, writing tests is a pain in the ass but the usefulness outweigh the pain, by a huge margin, trust me.
Let’s test our API and have a lot of fun doing it! ;)
Master Ruby Web APIs
APIs are evolving. Are you prepared?
The Bloggy series
You can find the full list of jutsus for this series on this page and the jutsu before this one is available there.
Getting the code
You can get the source code to follow this tutorial from GitHub if you didn’t follow the previous jutsu.
Coding Time ¯\(ツ)/¯
Alright, time to finish Bloggy. The tests we are going to write today will be the last thing we’ll do until the next series where we will build a Javascript front-end application to go with Bloggy. When that happens, we will probably tweak a few things and add authentication to the admin controller.
But for now, tests!
Writing tests is an art and most people have their own style. You can write tests in a huge number of ways. My main rules when I write tests is to try to keep them as simple as possible and keep the amount of expectations (asserts) to the minimum: 1. I also like to keep tests isolated and I don’t hesitate to create fake classes and stub (not too much though) for that purpose.
Also, I tend to follow the best practices described there.
If you have any comments about my testing style, I’d be happy to hear them. Don’t forget to be a civilized person so we can have a nice conversation. ;)
1. Setting up testing environment
We already setup our testing environment in the jutsu 17, when we were doing TDD for the Yumi library. If you didn’t follow that jutsu, head over there, follow the first part where we setup the testing environment and come back here.
Done? Good, let’s go!
2. Writing Rspec model tests
First, we are going to write model tests for posts, tags and comments. Our models are not super complicated so we’re just going to test that they have a valid factory and that they follow the validation rules we defined.
2.0 Rspec introduction
If you don’t know anything about Rspec, here is a quick introduction. Skip it if you’ve already used it in the past.
Rspec allows you to write assertions to ensure that the given input is equal to the expected output.
def multiply_by_2(a)
a * 2
end
it 'returns 6 when param is 3' do
expect(multiply_by_2(3)).to eq(6)
end
Simple right? Don’t worry the first tests in this tutorial are pretty easy too.
Before continuing, let’s go over some important vocabulary:
describe
: Define what we are testing. It can be a class or a string.context
: Allow us to group our tests in a logical way. For example, in the previous test, we could make a context from ‘when 3’ (butwhen integer
would make more sense) like this:
context 'when param is 3' do
it 'returns 6' do
expect(multiply_by_2(3)).to eq(6)
end
end
it
: Define a test. Give it a name and a block to run.expect
: Similar toassert
in other testing frameworks/languages. Will make the test fail if we don’t get what we’re expecting.
I hope this short introduction showed you enough to understand all the tests we are going to write now ;)
2.1 Post Tests
Before we test our models, we want to be sure that the factories we made in the jutsu 17 are good.
Testing that a model has a valid factory is pretty straight-forward. We just build it and use the neat be_valid
provided by Rspec.
We end up with something like this:
it 'has a valid factory' do
expect(build(:post)).to be_valid
end
And here is a reminder of how our factory looks like:
factory :post do
slug 'my-slug'
title 'My Title'
content 'Some Random Content.'
end
Obviously, it’s valid because we provide the slug and title. Next we are going to build the validation tests.
Here is the test that checks if the post is invalid without a slug:
it 'is invalid without a slug' do
expect(build(:post, slug: nil)).to_not be_valid
end
See how we use FactoryGirl build
method to build a post and pass a hash as a second parameter? Well this hash will override values in our default factory. Instead of creating a new factory, I like to use this feature because it makes the test very easy to read.
We use build and not create here because we don’t actually need to save the record in the database. Using build makes the tests run much faster because there are no communications with the database.
Here is the full testing file for post. Create the folder spec/models
and the file spec/models/post_spec.rb
before putting the following content in it. Read the whole content to get a good understanding of what we’re doing too!
# spec/models/post_spec.rb
require 'spec_helper'
describe Post do
it 'has a valid factory' do
expect(build(:post)).to be_valid
end
describe 'validations' do
it 'is invalid without a slug' do
expect(build(:post, slug: nil)).to_not be_valid
end
it 'is invalid without a title' do
expect(build(:post, title: nil)).to_not be_valid
end
it 'is invalid with a duplicated slug' do
create(:post)
expect(build(:post)).to_not be_valid
end
end
end
Run the specs with rspec spec/models/post_spec.rb
to see if everything works:
Output:
Finished in 0.06814 seconds (files took 1.05 seconds to load)
4 examples, 0 failures
2.2 Tag Tests
The Tag
tests are pretty similar to the ones we just wrote. Create the file spec/models/tag_spec.rb
and put the following inside:
# spec/models/tag_spec.rb
require 'spec_helper'
describe Tag do
it 'has a valid factory' do
expect(build(:tag)).to be_valid
end
describe 'validations' do
it 'is invalid without a slug' do
expect(build(:tag, slug: nil)).to_not be_valid
end
it 'is invalid without a name' do
expect(build(:tag, name: nil)).to_not be_valid
end
it 'is invalid with a duplicated slug in the same post' do
post = build(:post)
create(:tag, post: post)
expect(build(:tag, post: post)).to_not be_valid
end
end
end
Run the specs with rspec spec/models/tag_spec.rb
:
Output:
Finished in 0.11031 seconds (files took 1.43 seconds to load)
4 examples, 0 failures
2.3 Comment Tests
And finally the Comment
tests goes into spec/models/comment_spec.rb
:
# spec/models/comment_spec.rb
require 'spec_helper'
describe Comment do
it 'has a valid factory' do
expect(build(:comment)).to be_valid
end
describe 'validations' do
it 'is invalid without an author' do
expect(build(:comment, author: nil)).to_not be_valid
end
it 'is invalid without a content' do
expect(build(:comment, content: nil)).to_not be_valid
end
end
end
Run the specs with rspec spec/models/comment_spec.rb
to see if everything works:
Output:
Finished in 0.06424 seconds (files took 1.13 seconds to load)
3 examples, 0 failures
Alright, we just wrote tests for our Mongoid models, but that was the easy part.
3. Writing Rspec request tests
Now we are going to write tests for our controllers by making requests to our endpoints and see if the response match what we were expecting. Sounds like fun, right?
3.1 The Root Specs
First, we’re going to write tests for the API::Root
class which only contains one endpoint (/status
) and mount all our other controllers. We will be testing 2 things:
- Our
/status
endpoint works as expected when using the correct media type - The API cannot be accessed with an unsupported media type
Since we are testing a rack
application, we need to include Rack-Test
helper methods and create a method named app
that returns our application.
The code to do this is:
def app
OUTER_APP
end
It works with the OUTER_APP
constant that we defined in the spec/spec_helper.rb
file.
Currently, we are not requiring the rack-test
gem anywhere so let’s open the spec/spec_helper.rb
file and add it there.
# spec/spec_helper.rb
# We need to set the environment to test
ENV['RACK_ENV'] = 'test'
require 'ostruct'
require 'factory_girl'
require 'mongoid_cleaner'
# Add this!
require 'rack/test'
# ...
Alright, let’s create the folders spec/requests
and spec/requests/api
, and the file root_spec.rb
inside. Here is the content of this file. I put comments to explain everything so don’t just copy/paste it, read it ;)
# spec/requests/api/root_spec.rb
require 'spec_helper'
# Specify which class we want to test
describe API::Root do
# Rack-Test helper methods like get, post, etc
include Rack::Test::Methods
# required app method for Rack-Test
def app
OUTER_APP
end
# We are going to specifically test the /status
# endpoint so we use describe here
describe 'GET /status' do
# We define contexts depending on the media type
# in this case, we will use the media type application/json
context 'media-type: application/json' do
# This will be called every time before each following test
before do
# Set the header to application/json
header 'Content-Type', 'application/json'
# Make the actual request to /api/status using GET
get '/api/status'
end
# Define our first test. Since we're using a media type
# not supported, we expect 415
it 'returns HTTP status 415' do
expect(last_response.status).to eq 415
end
# The endpoint should also returns a JSON document
# containing the error 'Unsupported media type'
it 'returns Unsupported media type' do
expect(JSON.parse(last_response.body)).to eq(
{"error"=>"Unsupported media type"})
end
end
# For this context, we use the correct media type
context 'media-type: application/vnd.api+json' do
# We use a different approach here. See below for explanation.
# Basically we have our two asserts in the same test
# I kinda prefer the first approach but wanted to show you both
it 'returns 200 and status ok' do
header 'Content-Type', 'application/vnd.api+json'
get '/api/status'
expect(last_response.status).to eq 200
expect(JSON.parse(last_response.body)).to eq(
{ 'status' => 'ok' })
end
end
end
end
One thing to note in this test file. In the first context, I used only one assert per test while in the second context, I just one test with 2 asserts. I just wanted to show you the difference, there are pros and cons for each:
First approach (1 assert per test):
- You’re only testing one thing. If your test start to fail, you can easily identify the failing test.
- It will take longer to run and if you have a huge set of tests, it will take your tests suite longer to run.
Second approach (2 asserts in one test):
- Faster to run for huge tests suite.
- You have the whole request tested in one test.
- Harder to identify why a test is failing
- Does not follow best practices
One could argue that those asserts test the same thing because we are just testing the response. However, when building APIs that follow the hypermedia path, it’s important to not limit yourself to the response content. The headers are quite important and deserve their own tests.
For the rest of this jutsu, I use the first approach because I find it simpler to read and because Bloggy is not a huge application with a lot of tests :)
Alright, so we are done writing the API::Root
tests. Let’s continue our journey with the Posts
controller tests!
3.2 The Posts specs
Create the folder spec/requests/api/v1
and add the file spec/requests/api/v1/posts_spec.rb
inside. Here is the content for this file. Copy/paste it if you want but then READ IT because I put a bunch of comments to explain everything.
# spec/requests/api/v1/posts_spec.rb
require 'spec_helper'
describe API::V1::Posts do
include Rack::Test::Methods
def app
OUTER_APP
end
# Define a few let variables to use in our tests
let(:url) { 'http://example.org:80/api/v1' }
# We need to create a post because it needs to be in the database
# to allow the controller to access it
let(:post_object) { create(:post) }
before do
header 'Content-Type', 'application/vnd.api+json'
end
# Tests for the endpoint /api/v1/posts
describe 'get /' do
it 'returns HTTP status 200' do
get '/api/v1/posts'
expect(last_response.status).to eq 200
end
# In this describe, we split the testing of each part
# of the JSON document. Like this, if one fails we'll know which part
# is not working properly
describe 'top level' do
before do
post_object
get '/api/v1/posts'
end
it 'contains the meta object' do
expect(json['meta']).to eq({
'name' => 'Bloggy',
'description' => 'A simple blogging API built with Grape.'
})
end
it 'contains the self link' do
expect(json['links']).to eq({
'self' => "#{url}/posts"
})
end
# I got lazy and didn't put the whole JSON document I'm expected,
# instead I used the presenter to generate it.
# It's not the best way to do this obviously.
it 'contains the data object' do
expect(json['data']).to eq(
[to_json(Presenters::Post.new(url, post_object).as_json_api[:data])]
)
end
it 'contains the included object' do
expect(json['included']).to eq([])
end
end
# I want to test the relationships separately
# because they require more setup and deserve their own tests
describe 'relationships' do
# We need to create some related models first
let(:tag) { create(:tag, post: post_object) }
let(:comment) { create(:comment, post: post_object) }
# To avoid duplicated hash, I just use a method
# that takes a few parameters and build the hash we want
# Could probably use shared examples instead of this but I find
# it easier to understand
def relationship(url, type, post_id, id)
{
"data" => [{"type"=> type , "id"=> id }],
"links"=> {
"self" => "#{url}/posts/#{post_id}/relationships/#{type}",
"related" => "#{url}/posts/#{post_id}/#{type}"
}
}
end
# We need to call our let variables to define them
# before the controller uses the presenter to generate
# the JSON document
before do
tag
comment
get '/api/v1/posts'
end
# The following tests check that the relationships are correct
# and that the included array is equal to the number of related
# objects we created
it 'contains the tag relationship' do
id = tag.id.to_s
expect(json['data'][0]['relationships']['tags']).to eq(
relationship(url, 'tags', post_object.id, id)
)
end
it 'contains the comment relationship' do
id = comment.id.to_s
expect(json['data'][0]['relationships']['comments']).to eq(
relationship(url, 'comments', post_object.id, id)
)
end
it 'includes the tag and comment in the included array' do
expect(json['included'].count).to eq(2)
end
end
end
# Tests for the endpoint /api/v1/posts/1234567890
describe 'get /:id' do
# The post object is created before the request
# since we use it to build the url
before do
get "/api/v1/posts/#{post_object.id}"
end
it 'returns HTTP status 200' do
expect(last_response.status).to eq 200
end
# Repeat the same kind of tests than we defined for
# the index route. Could totally be in shared examples
# but that will be for another jutsu
describe 'top level' do
it 'contains the meta object' do
expect(json['meta']).to eq({
'name' => 'Bloggy',
'description' => 'A simple blogging API built with Grape.'
})
end
it 'contains the self link' do
expect(json['links']).to eq({
'self' => "#{url}/posts/#{post_object.id}"
})
end
it 'contains the data object' do
expect(json['data']).to eq(to_json(Presenters::Post.new(url, post_object).as_json_api[:data]))
end
it 'contains the included object' do
expect(json['included']).to eq([])
end
end
describe 'relationships' do
let(:tag) { create(:tag, post: post_object) }
let(:comment) { create(:comment, post: post_object) }
def relationship(url, type, post_id, id)
{
"data" => [{"type"=> type , "id"=> id }],
"links"=> {
"self" => "#{url}/posts/#{post_id}/relationships/#{type}",
"related" => "#{url}/posts/#{post_id}/#{type}"
}
}
end
before do
tag
comment
get '/api/v1/posts'
end
it 'contains the tag relationship' do
id = tag.id.to_s
expect(json['data'][0]['relationships']['tags']).to eq(
relationship(url, 'tags', post_object.id, id)
)
end
it 'contains the comment relationship' do
id = comment.id.to_s
expect(json['data'][0]['relationships']['comments']).to eq(
relationship(url, 'comments', post_object.id, id)
)
end
it 'includes the tag and comment in the included array' do
expect(json['included'].count).to eq(2)
end
end
end
end
That’s it for the Posts
controller tests. Now let’s see what we got for the Comments
controller.
3.3 The Comments Specs
You’re probably an expert tester by now! The following tests follow the same logic that we’ve seen before so it shouldn’t be too complicated.
Create the file spec/requests/api/v1/comments_spec.rb
and put the following code in it. Once again, the code is commented.
# spec/requests/api/v1/posts_spec.rb
require 'spec_helper'
describe API::V1::Comments do
include Rack::Test::Methods
def app
OUTER_APP
end
let(:url) { BASE_URL }
let(:post_object) { create(:post) }
# We need to define a set of correct attributes to create comments
let(:attributes) do
{
author: 'Tibo',
email: 'thibault@example.com',
website: 'devblast.com',
content: 'Super Comment.'
}
end
# And valid_params that use the previous attributes and
# add the JSON API spec enveloppe
let(:valid_params) do
{
data: {
type: 'comments',
attributes: attributes
}
}
end
# We also need an invalid set of params to test
# that Grape validates correctly
let(:invalid_params) do
{
data: {}
}
end
before do
header 'Content-Type', 'application/vnd.api+json'
end
describe 'POST /posts/:post_id/comments' do
# We use contexts here to separate our requests that
# have valid parameters vs the ones that have invalid parameters
context 'with valid attributes' do
# Now we're using post and not get to make our requests.
# We also pass the parameters we want
it 'returns HTTP status 201 - Created' do
post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
expect(last_response.status).to eq 201
end
# After the request, we check in the database that our comment
# was persisted
it 'creates the resource' do
post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
comment = post_object.reload.comments.find(json['data']['id'])
expect(comment).to_not eq nil
end
# Here we check that all the attributes were correctly assigned during
# the creation. We could split this into different tests but I got lazy.
it 'creates the resource with the specified attributes' do
post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
comment = post_object.reload.comments.find(json['data']['id'])
expect(comment.author).to eq attributes[:author]
expect(comment.email).to eq attributes[:email]
expect(comment.website).to eq attributes[:website]
expect(comment.content).to eq attributes[:content]
end
# Here we check that the endpoint returns what we want, in a format
# that follows the JSON API specification
it 'returns the appropriate JSON document' do
post "/api/v1/posts/#{post_object.id}/comments", valid_params.to_json
id = post_object.reload.comments.first.id
expect(json['data']).to eq({
'type' => 'comments',
'id' => id.to_s,
'attributes' => {
'author' => 'Tibo',
'email' => 'thibault@example.com',
'website' => 'devblast.com',
'content' => 'Super Comment.'
},
'links' => { 'self' => "#{BASE_URL}/comments/#{id}" },
'relationships' => {}
})
end
end
# What happens when we send invalid attributes?
context 'with invalid attributes' do
# Grape should catch it and return 400!
it 'returns HTTP status 400 - Bad Request' do
post "/api/v1/posts/#{post_object.id}/comments", invalid_params.to_json
expect(last_response.status).to eq 400
end
end
end
# Let's try to update stuff now!
describe 'PATCH /posts/:post_id/comments/:id' do
# We make a comment, that's the one we will be updating
let(:comment) { create(:comment, post: post_object) }
# What we want to change in our comment
let(:attributes) do
{
author: 'Tibo',
content: 'My bad.'
}
end
# Once again, separate valid parameters and invalid parameters
# with contexts. The tests don't have anything new compared to
# what we wrote for the creation tests.
context 'with valid attributes' do
it 'returns HTTP status 200 - OK' do
patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
expect(last_response.status).to eq 200
end
it 'updates the resource author and content' do
patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
expect(comment.reload.author).to eq 'Tibo'
expect(comment.reload.content).to eq 'My bad.'
end
it 'returns the appropriate JSON document' do
patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", valid_params.to_json
id = comment.id
expect(json['data']).to eq({
'type' => 'comments',
'id' => id.to_s,
'attributes' => {
'author' => 'Tibo',
'email' => 'thibault@example.com',
'website' => 'devblast.com',
'content' => 'My bad.'
},
'links' => { 'self' => "#{BASE_URL}/comments/#{id}" },
'relationships' => {}
})
end
end
context 'with invalid attributes' do
it 'returns HTTP status 400 - Bad Request' do
patch "/api/v1/posts/#{post_object.id}/comments/#{comment.id}", invalid_params.to_json
expect(last_response.status).to eq 400
end
end
end
# Let's delete stuff, yay \o/
describe 'DELETE /posts/:post_id/comments/:id' do
let(:comment) { create(:comment, post: post_object) }
# The request works...
it 'returns HTTP status 200 - Ok' do
delete "/api/v1/posts/#{post_object.id}/comments/#{comment.id}"
expect(last_response.status).to eq 200
end
# ... but did it really remove the comment from the DB?
it 'removes the comment' do
id = comment.id
delete "/api/v1/posts/#{post_object.id}/comments/#{id}"
comment = post_object.reload.comments.where(id: id).first
expect(comment).to eq nil
end
end
end
3.4 Testing!
Now that our tests are ready, we can run rspec
to run all the tests of our application!
rspec
Output:
Finished in 0.97313 seconds (files took 1.13 seconds to load)
74 examples, 0 failures
Awesome, everything works correctly! Now anytime we make a change to our API, we can re-run the tests to ensure that we didn’t break anything.
The best practice here is to actually setup a flow where your tests run every time you push some code to your repository (before it gets deployed) using some CI tool like CircleCI for example.
3.5 Writing the admin tests
I’m not going to show you the tests for the admin controller. I’d love to have you write them! It’s the perfect exercise to finish this jutsu. Not just this jutsu actually, the whole series about Bloggy!
What’s next?
This tutorial was the last one in the Bloggy series where we built an API from scratch using Grape, Mongoid and the JSON API specification - what a journey!
I originally wanted to include the jutsus to build the front-end in this series but I think it will go into a different one. We will obviously reuse the API we just built but since the main focus will be Javascript, it should be separated.
For now, I have to go write my book, see you soon!
Source Code
The source code is available on GitHub.