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:
It returns a value.
It passes the work along to somewhere else (i.e., dispatches the work elsewhere).
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.