Pure RSpec JSON API testing
ArticleIn this article we will have a look how to test JSON API in Ruby on Rails or in plain Ruby application with nothing more than RSpec 3.x
Entire source code of Dummy applicaion can be found here
Ruby on Rails example
Let say we have Article
model and ArticlesController
# app/models/article.rb
class Article < ActiveRecord::Base
def as_json
{
id: id,
title: title
}
end
end
# app/controllers/articles_controller.rb
class ArticlesController < ActionController::Base
before_action :find_article
def show
render json: @article.as_json
end
private
def find_article
@article = Article.find(params[:id])
end
end
In order to test this we can write a RSpec test like:
# spec/controllers/articles_controller_spec.rb
require 'rails_helper'
RSpec.describe ArticlesController, type: :controller do
describe "GET #show" do
before do
get :show, id: article.id
end
let(:article) { Article.create(title: 'Hello World') }
it "returns http success" do
expect(response).to have_http_status(:success)
end
it "response with JSON body containing expected Article attributes" do
hash_body = nil
expect { hash_body = JSON.parse(response.body).with_indifferent_access }.not_to raise_exception
expect(hash_body.keys).to match_array([:id, :title])
expect(hash_body).to match({
id: article.id,
title: 'Hello World'
})
end
end
end
So in the last it
statement we are evaluating single logical
assertion whether response body is parsable JSON format.
JSON.parse
will throw an exception if this is not true.
The result of this is a parsed Hash that we
convert to Rails
HashWithIndifferentAccess and we store this to local
variable hash_body
.
HashWithIndifferentAccess is typo of hash that in which symbol keys and string keys are considered same ( key
:id
is == key"id"
)
Then we are using built in RSpec
match_array
matcher to check if
expected keys are present. This is supper helpful to spot an early API
change (e.g. if a field was removed). Unlike eq
matcher the order of items is
not important.
Then we are using built in RSpec
match
matcher that compares
the hash elements. Unlike eq
matcher you can pass other matchers as
arguments.
This is also true to custom matchers you define ! Custom RSpec Matchers
# ...
expect(hash_body).to match({
id: be_kind_of(Integer),
title: match(/ello/)
})
# ...
Of course you don’t want to repeat this part in every controller:
hash_body = nil
expect { hash_body = JSON.parse(response.body).with_indifferent_access }.not_to raise_exception
Let’s introduce a custom matcher and some helpers for this:
# spec/spec_helper.rb
# ...
Dir["./spec/support/custom_matchers/**/*.rb"].each { |f| require f}
# ...
def body_as_json
json_str_to_hash(response.body)
end
def json_str_to_hash(str)
JSON.parse(str).with_indifferent_access
end
# spec/support/custom_matchers/json_matchers.rb
RSpec::Matchers.define :look_like_json do |expected|
match do |actual|
begin
JSON.parse(actual)
rescue JSON::ParserError
false
end
end
failure_message do |actual|
"\"#{actual}\" is not parsable by JSON.parse"
end
description do
"Expects to be JSON parsable String"
end
end
Now you can write:
# spec/controllers/articles_controller_spec.rb
# ...
it "response with JSON body containing expected Article attributes" do
expect(response.body).to look_like_json
expect(body_as_json.keys).to match_array([:id, :title])
expect(body_as_json).to match({
id: article.id,
title: 'Hello World'
})
end
# ...
Are you asking yourself: “Shouldn’t
expect(body).to look_like_json
andexpect(body_as_json).to match
be in separateit
blocks?”. Well yes and no, we are testing two things (that’s true) but both are there to ensure one logical assertion: “Is the body expected JSON ?” Thelook_like_json
is just a safety check for the case whenresponse.body
changes to some “non-json” (e.g. someone changes therender :json
withrender :html
, this give you meaningful message what went wrong before the comparison of JSON body even starts.If you want to learn more on this Test practices I’m recommending Bob Martins Clean Code: Advanced TDD
This is enough for a basic JSON APIs in a small applications with tiny API usage or in application where controllers specs test every scenario.
Going Plain Ruby - Serializer Objects
No matter if you’re using Rails, Sinatra or Volt once it goes to complex JSON APIs sticking all the JSON structure logic to Model or Controller is a bad idea. This should be responsibility of some Serializer object and Controller spec would just make sure it’s called correctly.
Most used Rails solution is gem ActiveModel Serializer but in this tutorial we are going to build our own Serializer object just to prove a point that you don’t need anything fancy.
Let say we want to build your API to comply jsonapi.org specification and the result should look like:
{
"article": {
"id": "305",
"type": "articles",
"attributes": {
"title": "Asking Alexandria"
}
}
}
# spec/serializers/article_serializer_spec.rb
require 'rails_helper'
RSpec.describe ArticleSerializer do
subject { described_class.new(article) }
let(:article) { instance_double(Article, id: 678, title: "Bring Me The Horizon") }
describe "#as_json" do
let(:result) { subject.as_json }
it 'root should be article Hash' do
expect(result).to match({
article: be_kind_of(Hash)
})
end
context 'article hash' do
let(:article_hash) { result.fetch(:article) }
it 'should contain type and id' do
expect(article_hash.keys).to match_array([:id, :title, :attributes])
expect(article_hash).to match({
id: article.id.to_s,
type: 'articles',
attributes: be_kind_of(Hash)
})
end
context 'attributes' do
let(:article_hash_attributes) { article_hash.fetch(:attributes) }
it do
expect(article_hash_attributes.keys).to match_array(:title)
expect(article_hash_attributes).to match({
title: /[Hh]orizon/,
})
end
end
end
end
end
# app/serializers/article_serializer.rb
class ArticleSerializer
attr_reader :article
def initialize(article)
@article = article
end
def as_json
{
article: {
id: article.id.to_s,
type: 'articles',
attributes: {
title: article.title
}
}
}
end
end
When we run our “serializers” specs everything passes. That’s pretty boring let’s introduce a
typo to our Article Serializer. Instead of type: "articles"
lets return type: "events"
and rerun our tests
rspec spec/serializers/article_serializer_spec.rb
.F.
Failures:
1) ArticleSerializer#as_json article hash should contain type and id
Failure/Error:
expect(article_hash).to match({
id: article.id.to_s,
type: 'articles',
attributes: be_kind_of(Hash)
})
expected {:id=>"678", :type=>"event",
:attributes=>{:title=>"Bring Me The Horizon"}} to match {:id=>"678",
:type=>"articles", :attributes=>(be a kind of Hash)}
Diff:
@@ -1,4 +1,4 @@
-:attributes => (be a kind of Hash),
+:attributes => {:title=>"Bring Me The Horizon"},
:id => "678",
-:type => "articles",
+:type => "events",
# ./spec/serializers/article_serializer_spec.rb:20:in `block (4
levels) in <top (required)>'
It’s pretty easy to spot the error. Let’s fix this error and introduce
a different error, tell the Serializer to return title with 3 l
rspec spec/serializers/article_serializer_spec.rb
..F
Failures:
1) ArticleSerializer#as_json article hash attributes should match
{:title=>(be a kind of String)}
Failure/Error:
expect(article_hash_attributes).to match({
title: be_kind_of(String),
})
expected {:titllle=>"Bring Me The Horizon"} to match {:title=>(be
a kind of String)}
Diff:
@@ -1,2 +1,2 @@
-:title => /[Hh]orizon/,
+:titllle => "Bring Me The Horizon",
# ./spec/serializers/article_serializer_spec.rb:31:in `block (5
levels) in <top (required)>'
The point of serializer objects is to make sure you deal with all the various scenarious in this test layer:
- maybe some attributes are lowercase is some scenarios
- maybe the serializer includes some nested resources (like Authors)
- maybe you can pass Article-alike object to serializer (Duck-type) “BlogPost” and you want to test the output behavior.
Hooking Serializer to Controller
So far Serializer is just a Ruby object that is not doing anything useful from application point of view. Lets tell our Controller to use it:
# app/controllers/v2/articles_controller.rb
module V2
class ArticlesController < ApplicationController
def show
render json: serializer.as_json
end
private
def article
@article ||= Article.find(params[:id])
end
def serializer
@serializer ||= ArticleSerializer.new(article)
end
end
end
As you can see we will just render the JSON hash via serializer
and pass it to render json: ...
Production code is the easy part, but in order to test this you need to ask yourself what test philosophy is your team following. Do you like Stubbed Controller tests or Integration Controller tests?
“Controller spec as an Integration test” version:
require 'rails_helper'
RSpec.describe V2::ArticlesController do
describe "GET #show" do
def trigger
get :show, id: article.id
end
let(:article) { Article.create(title: 'Hello World') }
it "returns http success" do
trigger
expect(response).to have_http_status(:success)
end
it "respond body JSON with attributes" do
trigger
expect(response.body).to look_like_json
expect(body_as_json).to be_kind_of(Hash)
end
it "correct article attributes are rendered" do
# we are not stubbing we will just make sure the Serializer is called
expect_any_instance_of(ArticleSerializer)
.to receive(:as_json)
.and_call_original # this will ensure the return value
# is called as it would normaly do
trigger
article_id = body_as_json
.fetch(:article)
.fetch(:id)
.to_i
expect(article_id).to eq article.id
end
end
end
In this kind of approach we just want to be sure our Serializer was called but we don’t need to test every attribute returned by JSON Body. That is already tested by Serializer test !.
We just want to be sure that correct Article JSON is rendered and we do that by checking the id.
“Stubbed Controller internals test” version:
If you are followers of Mockists test philosophy school. Your concern
is not to call something that we know is already working.
All you care is that the ArticleSerializer
object was constructed
with correct article
and as_json
was called in order to render
json: ...
require 'rails_helper'
RSpec.describe V2::ArticlesController do
describe "GET #show" do
def trigger
get :show, id: article.id
end
let(:article) { Article.create(title: 'Hello World') }
it "returns http success" do
trigger
expect(response).to have_http_status(:success)
end
context 'upon call' do
before do
serializer_double = instance_double(ArticleSerializer)
expect(ArticleSerializer)
.to receive(:new)
.and_return(serializer_double)
expect(serializer_double)
.to receive(:as_json)
.and_return({ article: 'stubbed hash by ArticleSerializer'})
trigger
end
it "uses ArticleSerializer to render body JSON" do
expect(body_as_json).to match({article: 'stubbed hash by ArticleSerializer'})
end
end
end
end
if you are hardcore Mockist you would probably want to do
expect(controlller).to receive(:render).with(json: serialization_hash_double)
.
Request test vs Controller specs
RSpec Rails provides Request Specs which are really great as they hit the actual endpoint all way through router as if you were
hitting real server (unlike Controller specs that are just ensuring
given route is defined in config/routes
and you are just testing
Controller / request / response object)
So as user bascule
pointed out in Reddit discussion for this article,
some developers may prefer them over Controller tests.
I fully agree. It’s really up to you which one you choose. Same rules apply for evaluating rendered body JSON.
Way how I understand it that main purpose of Controller specs should just help you do during your TDD session, and you can mock anything you want there (current_user, DB calls, Serializer calls,… for various scenarios).
Request spec should be the real “smoke test” / full integration test and you not necessary want many of them as they are slow and when errors are raised from Request test they are harder to track down as the error output is not that straight forward.
Therefore I personally recommending both but it’s really up to your team to decide how do you do the tests. Some teams don’t have time to write both or mock stuff in Controllers and therefore rather do the Integration testing in Controllers. I agree that “Good test practices fairies” are crying at that point but stuff needs to be pushed and deployed.
Why ?
Why even bother, why not to use JSON API testing gem like Airbourne ?
One of the benefits of Ruby on Rails community is the endless source of libraries for various usecases and test cases. When you’re building JSON API using Ruby you have many choices of gems how you going to test this.
Now this is all true and it is all awesome, but with every gem introduced our application/team rely on it. With every gem we introduce there is a promise developers will never allow gem to get out-dated in your application, otherwise in few years hell starts. More gems you introduce more this promise is harder to keep up with. Each time a gem version decides to change DSL there is a pressure to refactore code/tests.
Now this is not as that bad if you are maintaining 1 monolith application but more and more microservice architecture is becoming popular and you don’t necessary want/need to introduce 50 gems you are using everywhere else. Or let say you are building a gem yourself for JSON API you not necessary need to install gem like Airbourne
Airbourne gem is pretty good I’m not trying to trashtalk it I just want to show you that RSpec on it’s own is providing quite robust tool set that an cover some common scenarios.
If you using Airbourne to test controllers that’s fine, just consider
using RSpec match({})
testing for your serializer objects. The point
is that try to split JSON structure logic to Serializer object and use
whatever you like on controller.
Entire blog website and all the articles can be forked from this Github Repo