header image

Rails API with Active Model Serializers – Part 2

   Back to list

In the previous installment of this series, I covered how to build a solid API using Rails 5. Today, I want to show how we should test our API and how to write clean and readable tests using RSpec.

Tests are a really important part of our application. Without them, it’s hard to say which part of the application works, refactor some code or even add a new feature without breaking existing functions. Moreover, good specs are like a system documentation; they show how each part of the application or method should behave.

RSpec setup

We’ll use RSpec as a test framework instead of MiniTest. Please add the RSpec, FactoryGirl and ShouldaMatchers gems to the Gemfile:

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

gem 'rails', '~> 5.0.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.0'
gem 'active_model_serializers', '~> 0.10.0'
gem 'rack-cors'
gem 'rack-attack'
gem 'will_paginate'
gem 'pundit'

group :development, :test do
  gem 'pry-rails'
  gem 'faker'
  gem 'rspec-rails', '~> 3.5'
end

group :test do
  gem 'factory_girl_rails', '~> 4.0'
  gem 'shoulda-matchers', '~> 3.1'
end

group :development do
  gem 'bullet'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end
$ bundle install
  • RSpec is a test framework for Ruby and Rails applications. It’s really easy to write specs using it. Moreover, if you want test the front-end part of your application, just add the Capybara gem – it painlessly integrates with RSpec!
  • FactoryGirl is used to create factories for our tests, basically to build some records.
  • ShouldMatchers helps to test associations, validations and some controller methods.

Now when you know what each part of a system does, let’s install RSpec and initialize it:

$ rails generate rspec:install

And run it to check if everything works ok!

$ rspec

Gems setup

In this case adding gems to the Gemfile isn’t enough. We need to add a small configuration to the application. Please add to this line rails_helper.rb, which enables FactoryGirl:

...
  
RSpec.configure do |config|
  ...
    
  config.include FactoryGirl::Syntax::Methods
  
  ...
end

In order to configure ShouldaMatchers, add to rails_helper.rb:

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :active_record
    with.library :active_model
  end
end

Also we need to enable pundit test methods. Add pundit to rails_helper.rb (top of the file):

require 'pundit/rspec'

Add factories

Now that we have written some specs, let’s prepare all needed the factories. We need admin, user, author, book and book_copy factories. Create a folder called factories under specs and add the first factory – admin.

FactoryGirl.define do
  factory :admin, class: 'User' do
    admin true
    first_name 'Piotr'
    last_name 'Jaworski'
    sequence(:email) { |i| "my-email-#{i}@mail.com" }
  end
end

As you can see it’s really simple. We just define all needed attributes with their values. As you can see, to define an email, I used sequence. What is it? In each created factory, it fills a record with a sequence value. Why do we need it? Because an email address must be unique – and it is unique, thanks to sequence.

Now please add the rest of the needed factories:

FactoryGirl.define do
  factory :user do
    first_name 'Dummy'
    last_name 'User'
    sequence(:email) { |i| "dummy.user-#{i}@gmail.com" }
  end
end
FactoryGirl.define do
  factory :author do
    first_name 'Dummy'
    sequence(:last_name) { |i| "Author #{i}" }
  end
end
FactoryGirl.define do
  factory :book_copy do
    sequence(:isbn) { |i| "0000#{i}" }
    format 'hardback'
    published Date.today - 5.years
    association(:book)
  end
end
FactoryGirl.define do
  factory :book do
    association(:author)
    sequence(:title) { |i| "Book #{i}" }
  end
end

Model specs

Everything is ready to write the first spec – let’s do it. We need to test all the associations, validations and methods in the Author class. Create an author_spec.rb file under the specs/models:

require 'rails_helper'

describe Author do
  subject { create(:author) }

  describe 'associations' do
    it { should have_many(:books) }
  end

  describe 'validations' do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
  end
end

What are we testing here? We will check if all validations and associations are present. If you remove one, tests will fail (comment out associations):

  1) Author associations should have many books
     Failure/Error: it { should have_many(:books) }
       Expected Author to have a has_many association called books (no association called books)
     # ./spec/models/author_spec.rb:7:in `block (3 levels) in <top (required)>'

Now let’s test the BookCopy class. Testing associations and validations isn’t enough. We have 2 more methods there – borrow and return book. All of these methods should be tested in two ways – successful and unsuccessful scenarios. Let’s write the BookCopySpec:

require 'rails_helper'

describe BookCopy do
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }

  describe 'associations' do
    subject { book_copy }

    it { should belong_to(:book) }
    it { should belong_to(:user) }
  end

  describe 'validations' do
    subject { book_copy }

    it { should validate_presence_of(:isbn) }
    it { should validate_presence_of(:published) }
    it { should validate_presence_of(:format) }
    it { should validate_presence_of(:book) }
  end

  describe '#borrow' do
    context 'book is not borrowed' do
      subject { book_copy.borrow(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.borrow(user) }

      it { is_expected.to be_falsy }
    end
  end

  describe '#return_book' do
    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.return_book(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is not borrowed' do
      subject { book_copy.return_book(user) }

      it { is_expected.to be_falsy }
    end
  end
end

As you maybe noticed, I like to use one-liners. They’re really clear and readable to me. Moreover, I have a rule that I first declare variables using let. I call the before/after block later, and at the bottom I declare a subject. It really helps me to maintain, organize and read my code.

BookSpec looks really similar to BookCopySpec but there, we need to test the static-class method:

require 'rails_helper'

describe Book do
  let(:book) { create(:book) }

  describe 'associations' do
    subject { book }

    it { should have_many(:book_copies) }
    it { should belong_to(:author) }
  end

  describe 'validations' do
    subject { book }

    it { should validate_presence_of(:title) }
    it { should validate_presence_of(:author) }
  end

  describe '.per_page' do
    subject { described_class.per_page }

    it { is_expected.to eq(20) }
  end
end

Something new is in the UserSpec. Here we need to test the before_save callback. How? Well, we should check to see if a method has been called – using expect(instance).to receive(:method_name).

Another thing to test is if a method does what it should do. I check what happens before and after saving an instance:

require 'rails_helper'

describe User do
  let(:user) { create(:user) }

  describe 'associations' do
    subject { user }

    it { should have_many(:book_copies) }
  end

  describe 'validations' do
    subject { user }

    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:email) }
  end

  describe '#generate_api_key' do
    let(:user) { build(:user) }

    it 'is called before save' do
      expect(user).to receive(:generate_api_key)
      user.save
    end

    it 'generates random api key' do
      expect(user.api_key).to be_nil
      user.save
      expect(user.api_key).not_to be_nil
      expect(user.api_key.length).to eq(40)
    end
  end
end

Controller’s specs

Most of the important specs are controller’s specs. We must test if our endpoints work properly. Also, if we want to modify any method or refactor it, without specs it’s really hard to do.

Specs also have another responsibility, they show how each endpoint should behave. Moreover, in a case when each endpoint does something different for each user role, we should test all the possible cases. We don’t want to give an access to sensitive data for not-permitted users. Let’s start with writing specs for AuthorsController.

Let’s start at the index method. What should we test for here? If it’s accessible only for admins and to see if it returns a valid JSON with records. To pass HTTP Token, you can add to a before block:

before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }
describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end
    
    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

Now let’s test the show method. It should also not be accessible for users – only for admins. Moreover, a JSON should return attributes which are specified in a serializer.

...

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
  
...

Create method – also accessible only for admins. Moreover, we need to test two scenarios – with valid params and invalid – to check if our validations work.

...

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
 
...

Update method – the flow is really similar to the create method.

...

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
  
...

Destroy method – records can be removed only if the user is an admin.

As you probably noticed, I use one-liners to test each endpoint’s HTTP status, for example:

it { is_expected.to be_no_content }
it { is_expected.to be_unauthorized }

But not all HTTP statuses have methods which allow us to write tests like this – for example 422. There is not something like it (and if there is, I haven’t seen it :P):

it { is_expected.to be_unprocessable_entity }

In this case, you need to directly pass a HTTP status name:

it { is_expected.to have_http_status(:unprocessable_entity) }

Here is a full file with AuthorController’s specs, so if you get lost somewhere, feel free to check it:

require 'rails_helper'

describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: author.id } }

    before { author }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Author, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

BookCopiesController, BooksController and UsersController look almost the same as the AuthorsController so the specs are also almost the same. I won’t cover them, but below you can find the full files.

BookCopiesControllerSpec:

require 'rails_helper'

describe V1::BookCopiesController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }
  let(:book) { create(:book) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book_copy }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['book_copies'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book_copy.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_copy_params) { { isbn: '00001' } }

    subject { post :create, params: { book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '00001', published: Date.today, book_id: book.id, format: 'hardback' } }

        it { is_expected.to be_created }

        it 'creates an book_copy' do
          expect { subject }.to change(BookCopy, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_copy_params) { {} }

    subject { put :update, params: { id: book_copy.id, book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '0000033' } }

        it 'updates requested record' do
          subject
          expect(book_copy.reload.isbn).to eq(book_copy_params[:isbn])
          expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_copy_params) { { isbn: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book_copy.id } }

    before { book_copy }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(BookCopy, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

BooksControllerSpec:

require 'rails_helper'

describe V1::BooksController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book) { create(:book) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['books'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book: BookSerializer.new(book).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_params) { { title: nil } }

    subject { post :create, params: { book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title', author_id: author.id } }

        it { is_expected.to be_created }

        it 'creates a book' do
          expect { subject }.to change(Book, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_params) { {} }

    subject { put :update, params: { id: book.id, book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title' } }

        it 'updates requested record' do
          subject
          expect(book.reload.title).to eq(book_params[:title])
          expect(response.body).to eq({ book: BookSerializer.new(book.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_params) { { title: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book.id } }

    before { book }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Book, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

UsersControllerSpec:

require 'rails_helper'

describe V1::UsersController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { user }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['users'].length).to eq(2)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: user.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ user: UserSerializer.new(user).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:user_params) { { first_name: nil } }

    subject { post :create, params: { user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { first_name: 'Name', last_name: 'Last', email: '[email protected]' } }

        it { is_expected.to be_created }

        it 'creates a user' do
          expect { subject }.to change(User, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:user_params) { {} }

    subject { put :update, params: { id: user.id, user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { last_name: 'Last' } }

        it 'updates requested record' do
          subject
          expect(user.reload.last_name).to eq(user_params[:last_name])
          expect(response.body).to eq({ user: UserSerializer.new(user.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:user_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: user.id } }

    before { user }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(User, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

Now, I want to focus on two methods which are accessible to users and admins – borrow and return_book in the BookCopiesController.

There are a lot of cases; first let’s analyze the borrow method. A book can be borrowed by an admin, when he passes a user_id parameter. Without it, he can’t borrow. Moreover, a book copy cannot be borrowed if it’s already borrowed. As a user, we can borrow a book if it’s not borrowed – really simple.

Now the return_book method. An admin can return a book only with a user_id parameter. Moreover, user_id doesn’t need to match to a book copy’s user_id – an admin is an admin 🙂 and a not borrowed book cannot be returned.

A user can only return a borrowed book by himself, he cannot return a book that doesn’t belong to him.

require 'rails_helper'

describe V1::BookCopiesController do        
  ...
    
  describe '#borrow' do
    subject { put :borrow, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to be_successful }
        end

        context 'book is borrowed' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to have_http_status(:unprocessable_entity) }
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is not borrowed' do
        it { is_expected.to be_successful }
      end

      context 'book is borrowed' do
        before { book_copy.update_column(:user_id, admin.id) }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end
  end

  describe '#return_book' do
    subject { put :return_book, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to have_http_status(:unprocessable_entity) }
        end

        context 'book is borrowed' do
          context 'user_id matches to a book_copy user_id' do
            before { book_copy.update_column(:user_id, user.id) }

            it { is_expected.to be_successful }
          end

          context 'user_id does not match to a book_copy user_id' do
            let(:another_user) { create(:user) }

            before { book_copy.update_column(:user_id, another_user.id) }

            it { is_expected.to be_successful }
          end
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is borrowed' do
        context 'current user is a user who borrowed a book' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to be_successful }
        end

        context 'current user is not a user who borrowed a book' do
          let(:another_user) { create(:user) }

          before { book_copy.update_column(:user_id, another_user.id) }

          it { is_expected.to be_forbidden }
        end
      end

      context 'book is not borrowed' do
        it { is_expected.to be_forbidden }
      end
    end
  end
end

PolicySpecs

We should also test our policies. They’re a really important part of the application. Right now, in the application we only have one policy. Let’s write some specs – please add the book_copy_policy_spec.rb under the specs/policies folder.

require 'rails_helper'

describe BookCopyPolicy do
  let(:user) { create(:user) }

  subject { described_class }

  permissions :return_book? do
    context 'as admin' do
      it 'grants access if user is an admin' do
        expect(subject).to permit(Contexts::UserContext.new(nil, User.new(admin: true)), BookCopy.new)
      end
    end

    context 'as user' do
      it 'denies access if book_copy is not borrowed' do
        expect(subject).not_to permit(Contexts::UserContext.new(User.new, nil), BookCopy.new)
      end

      it 'grants access if book_copy is borrowed by a user' do
        expect(subject).to permit(Contexts::UserContext.new(user, nil), BookCopy.new(user: user))
      end
    end
  end
end

As you can see, we tested the return_book? method for an admin and a user. Each case should be covered. Pundit provides some useful methods for RSpec to make testing really simple.

Test coverage

One of the important things when we talk about testing is test coverage. It shows the amount of lines of code covered by tests. Also it shows which file has the least test coverage and which lines should be covered. SimpleCov is a great gem which can build a detailed report about our tests and show a test coverage rate.

Please add to the Gemfile (test group) and install:

gem 'simplecov', require: false
$ bundle install

To make it work, add at the top of the rails_helper.rb:

require 'simplecov'
SimpleCov.start

Now, when you run your tests, SimpleCov will prepare a report.

$ rspec

SimpleCov preparing a report

As you can see, our tests cover the code by 96.5% – which is a great result! Moreover, when you open the coverage/index.html file, it shows a detailed report, which covers how each file has been covered by specs.

test coverage

Conclusion

In this part of the series, I covered how to write readable and clear specs for your API. You should keep in mind that testing is a really important part of your application. Every good application should include specs which say how each part of the application should behave.

I hope that you liked this series and find it useful! The source code can be found here.

If you want to stay up to date with our articles, please subscribe to our newsletter. Feel free to add your comments and thoughts below!

Send this to a friend