Testing Rails JSON API with RSpec — Nimble

junan chakma
Nimble
Published in
7 min readJan 3, 2023

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:

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 and author columns:
class Book < ApplicationRecord
validates :title, :author, presence: true
end
  • a Book Controller with index, create and destroy 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 and author 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.

Originally published at https://nimblehq.co.

--

--