Testing our apps has become mandatory these days. For an app to be considered reliable it must have 95% or so of test coverage. Having that much coverage gives us courage to make changes in the code because we know that if something we change/add breaks our app those tests will warn us (fail!).
As our app grows, so do our tests. And if we don’t know how to improve our specs, we start having problems with test files becoming too complex to read and CI builds taking too long to complete. I’m not going to discuss here how much money companies lose from this, what I really want to show are some techniques that can really help you improve your testing skills.
Avoid database requests whenever possible
Requests to the database are one of the slowest operations in tests and, in most cases, they are totally unnecessary. With the popular factory_bot
gem, it’s better to use
, unless you really need to persist the data. This can really speed up your tests.build_stubbed
instead of
create
build_stubbed
works similar to build
; it makes objects look like they are persisted and has the capacity of creating associations using the "build_stubbed
" strategy (normal build
might still persist associations in the database) and stubs several methods that make use of the database.
# models/card.rb
class Card < ApplicationRecord
has_many :kinds
def character?
(kinds.pluck(:name) & Kind::CHARACTER_TYPES).any?
end
end
# models/kind.rb
class Kind < ApplicationRecord
CHARACTER_TYPES = %w[Creature Planeswalker Scariest Tribal Vanguard]
end
# spec/models/card_spec.rb
require 'rails_helper'
RSpec.describe Card, type: :model do
describe 'Given a creature character card' do
let(:type) { build_stubbed(:kind, name: 'Creature') }
let(:card) { build_stubbed(:card, kinds: [type]) }
it { expect(card.character?).to be_truthy }
end
end
Avoid making the same request over and over to test similar behaviors
When testing requests/controllers it’s common to have scenarios where a unique request can modify more than one table. For tests like this, chaining expectations will speed up the tests. E.g:
subject(:create_soldier) { post :create, params: params.to_json }
let(:params) do
{
soldier: {
name: 'Ryan',
age: 21,
soldier_inventory: {
item: 'ammo'
quantity: 250
}
}
}
end
it 'saves soldier with his inventory' do
expect { create_soldier }.to change(Soldier, :count).by(1)
.and change(SoldierInventory, :count).by(1)
end
IMPORTANT: This kind of construction is only valid when your tests aren’t isolated (e.g. ones that integrate with a DB, an external webservice, or, end-to-end-tests). For all other cases use only 1 assertion per test. (https://www.betterspecs.org/#single)
Make good use of Test Doubles
I could create a whole new article from this topic, but I’ll try to keep it simple.
Martin Fowler defines test doubles as:
a generic term for any case where you replace a production object for testing purposes
Making good use of test doubles (stub, mock, spies) can really speed up your tests.
When and why must you use doubles?
- When your code interacts with external APIs;
- When your code calls methods that can take too long to be processed;
- Because you don’t want to create dependencies in your tests;
- Sometimes you’ll need to create tests for classes or methods that do
not exist yet.
Stubs
It’s defined by Martin Fowler as:
provide canned answers to calls made during the test, usually not
responding at all to anything outside what’s programmed in for the test
This means we can preventively define a return value for a method without actually calling it:
# customer.rb
class Customer < ApplicationRecord
def request_loan(loan)
loan.process
end
end
# loan.rb
class Loan
def process
BankLoanAPI::Order.process(self) # return true or false
end
end
# /customer_spec.rb
describe '#process_loan' do
context 'with valid data' do
let(:customer) { build_stubbed(:customer) }
let(:loan) { create(:loan) }
it 'then loan is processed successfully'
allow(loan).to receive(:process).and_return(true) # stubs the external api call
expect(customer.request_loan(loan)).to be_truthy
end
end
end
We can increase test speed by creating a fake object for loan, since we are not actually using it. To do this, we should use instance_double:
describe '#process_order' do
context 'with valid data' do
let(:customer) { build_stubbed(:customer) }
let(:loan) { instance_double('Loan') }
it 'then order is processed successfully'
allow(loan).to receive(:process).and_return(true) # stubs the external api call
expect(customer.request_loan(loan)).to be_truthy
end
end
end
instance_double
creates an empty object that only responds to those methods we are stubbing and provide guarantees about what is being verified.
Mocks
Martin Fowler gives us another good definition for mocks:
Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don’t expect and are checked during verification to ensure they got all the calls they were expecting
Let’s suppose a customer wants to simulate a loan. Then, for this purpose we must change the request_loan
method.
# customer.rb
class Customer
def request_loan(loan)
return true if loan.simulation?
loan.process
end
end
If we run our tests again, they will pass. But this is not the correct scenario we want to test. We want to make sure that loan.process
will run for some scenarios. For cases like this, we can mock it.
describe '#process_order' do
context 'with valid data' do
let(:customer) { build_stubbed(:customer) }
let(:loan) { instance_double('Loan') }
context 'when is not a simulation' do
before do
allow(loan).to receive(:simulation?).and_return(false)
allow(loan).to receive(:process).and_return(true) # stubs the external api call
end
it 'then order is processed successfully'
expect(loan).to receive(:process).and_return(true)
customer.request_loan(loan)
end
end
context 'when is a simulation' do
before do
allow(loan).to receive(:simulation?).and_return(true)
end
it 'then order is not processed' do
expect(loan).not_to receive(:process)
customer.request_loan(loan)
end
end
end
end
Spies
Martin Fowler defines spies as:
are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
As you might have noticed, mocking usually breaks the usual flow of the tests, since we are setting the expectations before executing the code we are testing. Based on MF definition, we can use spies to keep the standard flow of code execution.
describe '#process_order' do
context 'with valid data' do
let(:customer) { build_stubbed(:customer) }
let(:loan) { instance_spy('Loan') }
before { allow(loan).to receive(:process).and_return(true) }
it 'then order is processed successfully'
customer.request_loan(loan)
expect(loan).to have_received(:process).once
end
end
end
Conclusion
RSpec still has many others great features, but I will leave them for another article. Make good use of these great features and your specs will be much more readable and faster than ever.
We want to work with you. Check out our "What We Do" section!