As you've probably guessed by the title of my article, I still consider Ruby on Rails as a relevant technology that offers a lot of value, especially when combined with ReactJS as it's frontend counterpart. Here's how I approach the topic.
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
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.
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!