For most of our Rails app client projects, we usually need to develop the APIs following the JSON:API specification, and other third-party apps consume these API endpoints. It is crucial for us to build solid, stable, and performant APIs. To have a solid API, we also need to have solid tests too, for the APIs.
This post demonstrates how to test Rails JSON API endpoints with the RSpec testing framework.
Setting Up the Necessary Gems
We are going to set up the following gems to make our testing experience much easier:
- rspec-rails for the RSpec testing framework
- jsonapi-serializer for the JSON API serializer
- fabrication for generating Ruby objects
- json_matchers for validating JSON APIs response
Install rspec-rails
Add the rspec-rails dependency in the Gemfile
, inside :development
and :test
group:
group :development, :test do
gem 'rspec-rails'
end
Then run bundle install
to install it on the local machine. After installing the gem, run the following command to configure it in the rails app:
rails generate rspec:install
It will create some boilerplate files to get configured with the rails app.
Install jsonapi-serializer, fabrication, and json_matchers
Add jsonapi-serializer, fabrication and json_matchers gem in the Gemfile
like:
gem 'fabrication'
gem 'jsonapi-serializer'
group :test do
gem 'json_matchers'
end
Then run bundle install
to install all the gems in the local machine.
After installing the gems, we need to configure it with RSpec like:
# spec/spec_helper.rb
require "json_matchers/rspec"
JsonMatchers.schema_root = 'spec/support/api/schemas'
The API Endpoints
For this demo, we will use the following classes:
-
a Book model with the
title
andauthor
columns:class Book < ApplicationRecord validates :title, :author, presence: true end
-
a Book Controller with
index
,create
anddestroy
methods:module API class BooksController < ApplicationController protect_from_forgery with: :null_session before_action :set_book, only: :destroy def index render json: BookSerializer.new(books) end def create @book = Book.new(book_params) if @book.save head :created else render_errors(details: @book.errors.full_messages, code: :validation_error) end end def destroy if @book.destroy head :no_content else render_errors(details: @book.errors.full_messages, code: :validation_error) end end private def render_errors(jsonapi_errors, status = :unprocessable_entity) render json: { errors: jsonapi_errors }, status: status end def set_book @book = Book.find(params[:id]) end def books Books.all end def book_params params.permit(:title, :author) end end end
-
a Book Serializer with
title
andauthor
attributes:class BookSerializer include JSONAPI::Serializer attributes :title, :author end
So there will be three API endpoints:
-
GET /api/books
- Book listing API endpoint -
POST /api/books
- Book creating API endpoint -
DELETE /api/books/:id
- Book deleting API endpoint
Example response of the API endpoints
All the API responses will be JSON:API compliance responses
The Book listing API endpoint example response is like:
{
"data": [
{
"id": "1",
"type": "books",
"attributes": {
"title": "A Brief History of Time",
"author": "Stephen Hawking"
}
},
{
"id": "2",
"type": "books",
"attributes": {
"title": "Sapiens : A Brief History of Humankind",
"author": "Yuval Noah Harari"
}
}
]
}
The Book creating API endpoint’s example success response will be 201
status code without the response body and an example error response will be like:
{
"errors": {
"details": ["Title can't be blank", "Author can't be blank"],
"code": "validation_error"
}
}
The Book deleting API endpoint’s example success response will be 204
status code without the response body.
Testing the endpoints with RSpec
Factory setup
We need to first define the Book factory so that we can easily generate the book instance in the test.
A very popular way to build a factory with Ruby is using the Fabrication gem.
Define the Book factory like:
# spec/fabricators/book_fabricator.rb
Fabricator(:book) do
title 'The Code Breaker'
author: 'Walter Isaacson'
end
Now we can create a Book instance easily in our tests using Fabricate(:book)
Define JSON schema
We have already installed the json_matchers gem. Now we need to define the Book JSON Schema to match the book API JSON response with the Book JSON Schema.
Define the Book JSON Schema at spec/support/api/schemas/books.json
like:
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"data": {
"type": "array",
"items": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"attributes": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"author": {
"type": "string"
}
},
"required": ["title", "author"]
}
},
"required": ["id", "type", "attributes"]
}
]
}
},
"required": ["data"]
}
Define the error
JSON Schema at spec/support/api/schemas/errors.json
like:
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"errors": {
"type": "object",
"properties": {
"details": {
"type": "array",
"items": [
{
"type": "string"
}
]
},
"code": {
"type": "string"
}
},
"required": ["details", "code"]
}
},
"required": ["errors"]
}
GET /api/books
- Book listing API endpoint
Now, let’s write the test for the Book listing API endpoint. Define the books_controller_spec.rb
like:
# spec/requests/api/books_controller_spec.rb
require 'rails_helper'
describe Api::BooksController, type: :request do
describe 'GET /api/books' do
it 'responds with ok status' do
get api_books_path
expect(response).to have_http_status :ok
end
it 'responds with books' do
Fabricate(:book, title: 'A Brief History of Time', author: 'Stephen Hawking')
Fabricate(:book, title: 'Sapiens : A Brief History of Humankind', author: 'Yuval Noah Harari')
get api_books_path
expect(response.body).to match_response_schema('books', strict: true)
end
end
end
The first test block verifies that the API returns a 200
status code. To test that, it first requests the endpoint, then checks the HTTP response code with:
expect(response).to have_http_status :ok
to verify the status code is 200
.
In the next test block, it verifies the HTTP response with json_matchers
gem to validate it is returning JSON:API compliance response. To test that, it first creates two records with the fabrication
gem and requests the endpoint. Then it matches the HTTP response body with the book JSON schema we defined before.
To verify it, you can run the test by running:
rspec spec/requests/api/books_controller_spec.rb
You should see both tests are passing.
POST /api/books
- Book creating API endpoint
Now, let’s write the test for the Book creating API endpoint. Define the code changes like:
# spec/requests/api/books_controller_spec.rb
require 'rails_helper'
describe Api::BooksController, type: :request do
# Book listing API endpoint spec goes here
#...
describe 'POST /api/books' do
context 'given valid params' do
it 'responds with created status' do
params = {
title: 'A Brief History of Time', author: 'Stephen Hawking'
}
post api_books_path, params: params
expect(response).to have_http_status :created
end
it 'returns an empty response body' do
params = {
title: 'A Brief History of Time', author: 'Stephen Hawking'
}
post api_books_path, params: params
expect(response.body).to be_empty
end
end
context 'given invalid params' do
it 'responds with unprocessable_entity status' do
params = {
title: '', author: 'Stephen Hawking'
}
post api_books_path, params: params
expect(response).to have_http_status(:unprocessable_entity)
end
it 'responds with an error' do
params = {
title: '', author: 'Stephen Hawking'
}
post api_books_path, params: params
expect(response.body).to match_response_schema('errors', strict: true)
end
end
end
end
In the first context, given valid params
:
-
The first test block verifies that the API returns a
201
status code. To test that, it first requests the endpoint with valid params, and then it checks the HTTP response code with:expect(response).to have_http_status :created
to verify the status code is
201
.Then in the next test block, it verifies that the HTTP response body is empty with:
expect(response.body).to be_empty
In the second context, given invalid params
:
-
In the first test block verifies that it returns an error response with
422
status code. To test that, it first requests the endpoint with invalid params, and then it checks the HTTP response code with:expect(response).to have_http_status(:unprocessable_entity)
to verify the status code is
422
.Then in the next test block, it verifies the HTTP response with
json_matchers
gem to validate it is returning JSON:API compliance error response. To test that, it first requests the endpoint with invalid params and then it matches the HTTP response body with the error JSON schema we defined before.To verify it, you can run the test by running:
rspec spec/requests/api/books_controller_spec.rb
You should see all the tests are passing.
DELETE /api/books/:id
- Book deleting API endpoint
Now, let’s write the test for the Book deleting API endpoint. Define the code changes like:
# spec/requests/api/books_controller_spec.rb
require 'rails_helper'
describe Api::BooksController, type: :request do
# Book listing API endpoint spec goes here
#...
# Book creating API endpoint spec goes here
# ...
describe 'DELETE /api/books/:id' do
it 'responds with no_content status' do
book = Fabricate(:book, title: 'A Brief History of Time', author: 'Stephen Hawking')
delete api_book_path(book.id)
expect(response).to have_http_status :no_content
end
it 'returns an empty response body' do
book = Fabricate(:book, title: 'A Brief History of Time', author: 'Stephen Hawking')
delete api_book_path(book.id)
expect(response.body).to be_empty
end
end
end
The first test block verifies that the API returns a 204
status code. To test that, it first requests the endpoint and then it checks the HTTP response code with:
expect(response).to have_http_status :no_content
to verify the status code is 204
.
In the next test block, it verifies that the HTTP response body is empty with:
expect(response.body).to be_empty
To verify it, you can run the test by running:
rspec spec/requests/api/books_controller_spec.rb
You should see all the tests are passing.
Conclusion
In the Rails community, it has become a de facto standard to write tests against your code, and it is more crucial to write tests for the API endpoints to verify the correctness of the API responses. With the help of Rspec
gem, we can easily validate our API responses from the tests.