Unit Testing in Ruby

Written by: Leigh Halliday
8 min read

In his article on Unit Testing, Martin Fowler mentions that what a unit actually is can change depending on what school of thought you come from. Many from the object-oriented world consider the Class as the unit, whereas those in the functional programming world consider the Function the unit.

Even though Ruby is an object-oriented language, I tend to see the method/function as the unit. This is what should be tested, focusing on those methods which comprise the public interface of a particular class. Private methods can be tested through their public interfaces, allowing the actual implementation of them to change and be refactored as needed.

In this article, we'll look at a number of ways in which we can perform unit testing in Ruby. We'll look at how we can isolate our units from their dependencies and also how we can verify that our code is working correctly.

Testing Is Important... and Opinionated

You only have to look as far as DHH to see that with testing come opinions.

With testing, there are many good reasons to either push for complete isolation from unit to unit or to allow the system to work together as it was built, with dependencies and all. The most important thing is that you actually do test, no matter which methodology you follow.

I am not entirely against testing private methods if it can help the programmer verify that they are working correctly, especially as part of TDD, so it should be left up to the individual to make a decision. Just keep in mind that private implementation is more likely to change than public API -- these tests are more likely to be rewritten and/or completely thrown out when their usefulness is gone.

Tests and Test Driven Development should help you write better code that you are more confident about. Follow the methodology that helps you achieve that.

What should be tested?

Given that we're going to be testing methods, what do we actually test? What are we trying to prove by testing them? The thing to ask is what the method's purpose is. That is what should be tested. What does it set out to accomplish?

I imagine there are others, but I am going to group a method's purpose into three different categories:

  1. It returns a value.

  2. It passes the work along to somewhere else (i.e., dispatches the work elsewhere).

  3. It causes a side effect.

What shouldn't be tested?

When unit testing, it is important to avoid testing the entire system (which would be integration testing -- also important). It's also especially important to avoid external dependencies, among the worst of them being an external API call.

External dependencies can and will fail. You should plan for what would happen if an external API is down, but you shouldn't be testing that it does its job correctly. Let it worry about that while you focus on what your unit of code is doing.

By avoiding external dependencies you are also helping to speed up your tests. Reading from a file or talking to a database is slow, and talking to an external HTTP API is even slower. Besides, they most likely don't want you hitting their API every time you (or your CI server) run the test suite.

Examples of Unit Testing

The code we are going to use in the following examples involves a number of classes, all relating to a gift card system.

There will be a Giftcard class, which in a Rails app would be an ActiveRecord model. There will be another class called Giftcards::Repository, whose job it is to perform different actions on the gift card such as creating a new one, checking the balance, adjusting the balance, and canceling the gift card. Lastly, we have a number of different adapters whose job it is to talk with the issuer of each gift card.

We'll work with a Cardex adapter and a Test adapter (a fake adapter for use in tests).

class Giftcard
  attr_accessor :card_number, :pin, :issuer, :cancelled_at
  def self.create!(options)
    self.new.tap do |giftcard|
      options.each do |key, value|
        giftcard.send("#{key}=", value) if giftcard.respond_to?("#{key}=")
      end
    end
  end
  def masked_card_number
    "#{'X' * (card_number.length - 4)}#{last_four_digits}"
  end
  def cancel!
    self.cancelled_at = Time.now
    save!
  end
  def save!
    true
  end
  private
  def last_four_digits
    card_number[-4..-1]
  end
end

Below we have our Giftcards::Repository class, the bridge between the Giftcard class and the different adapter classes.

module Giftcards
  class Repository
    attr_reader :adapter
    def initialize(adapter)
      @adapter = adapter
    end
    def create(amount_cents, currency)
      details = adapter.create(amount_cents, currency)
      Giftcard.create!({
        card_number: details[:card_number],
        pin: details[:pin],
        currency: currency,
        issuer: adapter::NAME
      })
    end
    def balance(giftcard)
      adapter.balance(giftcard.card_number)
    end
    def adjust(giftcard, amount_cents)
      adapter.adjust(giftcard.card_number, amount_cents)
    end
    def cancel(giftcard)
      amount_cents = balance(giftcard)
      adjust(giftcard, -amount_cents)
      giftcard.cancel!
      giftcard
    end
  end
end

Next are the adapters, which aren't fully fleshed out yet, but for the purposes of this example they'll do fine. Their interface is locked down, and they can be implemented and tested individually at a later time.

This is where all the nasty SOAP calls would go (if you've worked with real gift card issuers, you'll know what I'm talking about), or if you're lucky, a JSON API (highly doubtful).

module Giftcards
  module Adapters
    class BaseAdapter
      # To be implemented
    end
    class Cardex < BaseAdapter
      # To be implemented
    end
    class Test < BaseAdapter
      NAME = 'test'.freeze
      def create(amount_cents, currency)
        pool = (0..9).to_a
        {
          card_number: (0..11).map{ |n| pool.sample }.join,
          pin: (0..4).map{ |n| pool.sample }.join,
          currency: currency
        }
      end
      def balance(card_number)
        100
      end
      def adjust(card_number, amount_cents)
        balance(card_number) + amount_cents
      end
      def cancel(giftcard)
        true
      end
    end
  end
end

Isolating Dependencies

Isolating these dependencies can either be accomplished by using dependency injection, whereby you inject stub dependencies (an API wrapper which is specifically for testing and returns you a canned response immediately), or by using some form of test double or method mocking/stubbing.

Using doubles

Doubles are a sort of "fake" version of dependent objects. They are most of the time incomplete representations, which only expose the methods necessary for performing the test. Rspec comes with a great library called rspec-mock which includes all sorts of different ways to produce these doubles.

One example, which I'll use below, is to create a fake instance of the Giftcard class that only has the card_number method (and the value it returns).

instance_double('Giftcard', card_number: 12345)

Mocking HTTP requests

There are a number of ways to mock HTTP requests, and we won't dive into them here. In an article I wrote about micro-services in Rails, there's a pretty good example on mocking an HTTP request using the Faraday gem.

Other ways to mock HTTP requests might involve using the webmock gem. Or you can use VCR, which makes a real request once, records the response, and then uses the recorded response from then on.

Stubbing methods

Because of the way Ruby is written, we can actually replace an object's method with a fake version. This is done in rspec with the following code:

allow(adapter).to receive(:adjust) { 50 }

Now when adapter.adjust is called, it will return the value 50 no matter what, instead of performing the expensive SOAP call to the real service. The complete test looks as follows:

describe Giftcards::Repository do
  describe '#adjust' do
    let(:adapter)    { Giftcards::Adapters::Cardex.new }
    let(:repository) { Giftcards::Repository.new(adapter) }
    let(:giftcard)   do
      Giftcard.new do |giftcard|
        giftcard.card_number = '12345'
      end
    end
    it 'returns new balance' do
      allow(adapter).to receive(:adjust) { 50 }
      expect(repository.adjust(giftcard, -50)).to eq(50)
    end
  end
end

Dependency injection

Dependency injection is where you pass an object's dependencies to it. In this case, instead of the Giftcards::Repository class deciding which adapter it will use, we can actually explicitly pass the adapter to it, putting more control in the caller's hands.

This technique helps us with testing, because we can replace the real object with one specifically created for testing.

adapter = Giftcards::Adapters::Test.new
repository = Giftcards::Repository.new(adapter)

Verifying Our Code Works as Expected

The real reason we write tests is so that we can be confident that our code works as expected. Dealing with dependencies is nice, but if it doesn't help us verify that our code works, it's rather pointless.

Testing return value

The simplest form of unit testing is to verify that our function returns the correct value when it is called. There are no side effects here... we simply call it and expect a result to come back.

describe Giftcard do
  describe '#masked_card_number' do
    it 'masks number correctly' do
      giftcard = Giftcard.new
      giftcard.card_number = '123456780012'
      expect(giftcard.masked_card_number).to eq('XXXXXXXX0012')
    end
  end
end

Testing other method called correctly

There are cases when the purpose of the method is to dispatch some sort of work to another method. Or in other words, it calls a method. Let's write a test to verify that this other method was called correctly. Rspec actually has a mechanism for finding this out, where we can check to make sure that an object received a certain method call with specific arguments.

describe Giftcards::Repository do
  describe '#balance' do
    let(:adapter)    { Giftcards::Adapters::Test.new }
    let(:repository) { Giftcards::Repository.new(adapter) }
    let(:giftcard)   { instance_double('Giftcard', card_number: 12345) }
    it 'returns balance for giftcard' do
      expect(repository.balance(giftcard)).to eq(100)
    end
    it 'calls the balance of adapter' do
      expect(repository.adapter).to receive(:balance).with(giftcard.card_number)
      repository.balance(giftcard)
    end
  end
end

Testing side effects

Lastly, our method may produce some side effects. These could be anything from changing the state of an object (an instance variable's value) to writing to the database or a file. The important thing here is to check both the before state and the after state, so that you can be sure the side effect happened.

describe Giftcard do
  describe '#cancel!' do
    it 'updates cancelled_at field to current time' do
      giftcard = Giftcard.new
      expect(giftcard.cancelled_at).to eq(nil)
      giftcard.cancel!
      expect(giftcard.cancelled_at).to be_a(Time)
    end
  end
end

Single responsibility

If your method doesn't fall into one of the three categories above -- meaning that its purpose isn't defined by a result, a method call, or a side effect -- perhaps it's doing too much. In that case, it should be broken down further into a series of single purpose methods.

By following the single responsibility principle, your code will be much easier to test.

Conclusion

We've covered a number of different ways to isolate dependencies in our code while writing unit tests, as well as how to verify that the method does what it's supposed to do. The most important part of testing is to actually do it. I saw on Twitter recently a Beyonce-inspired quote which seems appropriate now:

If you love your code, put a test on it.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.