Dependency Injection and Testability in Ruby
This past week I was pairing with a teammate, and during the pair session, we asked many times, “How this part of the code could be more explicit and testable?”.
In this post, let’s explore a bit more of this idea. Keep in mind this is much more than testing. It’s about good design!
The title is a bit broad and would be hard to explain in detail and compare dependency injection (DI) and inversion of control (IoC) like in other languages/frameworks.
Talking dependencies, I prefer to be explicit as I’ve had experience working in codebases where things were instantiated all over the code, being hard to test and understand.
Let’s begin with an example in Ruby. Think of a class, in this case, a Person
. This class has a method that uses a third-party service that, given a place, returns geolocation data.
Suppose I live in Sao Paulo/Brazil and I want to find its latitude and longitude.
There are two different ways of achieving the same result for this example, so let’s dive in.
First, it’s possible to declare a dependency using a class method:
class Person
def self.geo_data(geo_service: GoogleMapsService.new, address:)
lat, long = geo_service.find_by(address)
[lat, long]
end
end
A class method receives its dependency and the address
parameter, just that.
I usually try to think more in OOP terms in Ruby, so I must have good reasons to use class methods, but keep in mind they’re helpful occasionally.
The other option that I find particularly interesting is declaring the dependency explicitly via constructor using a named parameter with a default value:
class Person
def initialize(geo_service: GoogleMapsService.new)
@geo_service = geo_service
end
def geo_data(address)
lat, long = @geo_service.find_by(address)
[lat, long]
end
end
person = Person.new
geo_data = person.geo_data('Avenida Paulista - Bela Vista, São Paulo - SP')
puts geo_data # [-23.533773, -23.533773]
Both choices are valid, and depending on how the service is used, favor one or another. It’s a matter of style that your codebase is very likely already using, so try to stick with it.
In my opinion, now the code has a better explicit design. By looking at the constructor, we can see its dependencies.
Good, but let’s try to look at how it can make our tests more testable now. As you can imagine, calling an external service on your tests can make it slower and error-prone, like in the example where we need to invoke @geo_service.find_by
. Before we see the alternative, notice that it’s possible to use the always interesting VCR gem for this, but not always needed.
We usually want our tests leaner and faster, so let’s implement the FakeMapsService
that could replace GoogleMapsService
:
class FakeMapsService
def find_by(address)
# Notice that it has the same signature and return type as the real service method
[1, 2]
end
end
person = Person.new(geo_service: FakeMapsService.new)
geo_data = person.geo_data('Avenida Paulista - Bela Vista, São Paulo - SP')
puts geo_data # [1, 2]
It’s a class that respects the same contract as the real service, having an instance method find_by(address)
that returns the same array containing latitude and longitude.
Even though a contrived example, FakeMapsService
makes it easier to test now, avoiding the whole mockery we get used to:
require 'test/unit'
require 'test/unit/assertions'
require_relative './person'
class PersonTest < Test::Unit::TestCase
def test_geo_data
# When creating our scenario, we change the real service for the fake one
person = Person.new(geo_service: FakeMapsService.new)
assert_equal person.geo_data('Wonderland, Nowhere'), [1, 2]
end
end
So, that’s it for now. It’s a simple yet powerful idea that I’ve used many times in Ruby and also Go! In the next post, I’ll cover a different example in Go about using the same approach for another problem.
I hope you enjoyed it, and thanks for reading!