Testing Rails JSON API with RSpec

Testing Rails JSON API with RSpec

A guide to testing RAILS JSON API with Rspec
Junan Chakma
Junan Chakma
October 17, 2022
Web

Table of Contents

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.

If this is the kind of challenges you wanna tackle, Nimble is hiring awesome web and mobile developers to join our team in Bangkok, Thailand, Ho Chi Minh City, Vietnam, and Da Nang, Vietnam✌️

Join Us

Recommended Stories:

Accelerate your digital transformation.

Subscribe to our newsletter and get latest news and trends from Nimble