Exploring RSpec – Improving Your Testing Skills

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 build_stubbed instead of create, unless you really need to persist the data. This can really speed up your tests.

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!