Ruby RSpec: Mocks, Doubles, and Spies with Constructor Injection

Inversion of Control (IOC) containers or Dependency Injection is a common design pattern used to help engineers "inject" different instances of an object at runtime into services, controllers, or gateway classes. This concept is somewhat foreign to the Ruby on Rails community but valuable in some contexts. Most unit tests in RoR won't utilize mocks and stubs and instead opt for direct integration with the database. This article provides an alternative to integration testing by unit testing through constructor injection.


In this example, we will test the business logic that operates between an API request and an external, third-party web service.


I will note that this pattern forces developers to consider not using patterns like that in active record callbacks. The problem with callbacks is they are hard to test and can conflate what business rules exist in a domain context. Instead, you can move these operations into "service" classes. This enables future developers to better see the workflow and lifecycle of a domain model and its dependent resources. It forces you to be more intentional and ask what am I trying to solve and how can I communicate this through code enforcing patterns and succinct testing.

Song Model

This model represents a song object that can encode and decode to/from JSON.
class Song
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Serializers::JSON
attr_accessor :name, :artist, :genre, :id
validates_presence_of :name, :artist
def attributes=(hash)
hash.each do |key, value|
send("#{key}=", value)
end
end
def attributes
{
'name' => nil,
'artist' => nil,
'genre' => nil,
'id' => nil,
}
end
end
view raw song.rb hosted with ❤ by GitHub

Song Service

This class instruments the business logic and handles interactions with the external API using a "Gateway Service."
class SongService
attr_reader :gateway_service
def initialize(gateway_service: SongGatewayService.new)
@gateway_service = gateway_service
end
# Creates a song
# @param [Song] song
# @return [Song] created song
def create_song(song)
song.validate!
result = gateway_service.create_song(song)
created_song = Song.new
created_song.from_json(result)
created_song
end
end
view raw song_service.rb hosted with ❤ by GitHub

Song Gateway Service

This API (gateway) service enables us to proxy calls to the external API. This instruments the HTTP interfaces and allows us to mock these calls during testing.

Dependency Injection

If you notice in the song service, we "inject" the gateway service by passing it through the initializer. This is called "Constructor Injection."
def initialize(gateway_service: SongGatewayService.new)
@gateway_service = gateway_service
end
view raw di.rb hosted with ❤ by GitHub
We can then override the gateway class when testing the song service by injecting a mock or stubbed version. This allows us to avoid implementing the HTTP endpoints enabling a unit level of testing.

Testing with Mocks

When writing tests, we can inject a new implementation of the gateway service. One way to do this is to implement a new class that implements all methods within the scope of the test.
RSpec.describe SongService do
require 'spec_helper'
subject { SongService.new(gateway_service: gateway_service) }
let(:song) { Song.new(name: 'Seek Up', artist: 'Dave Matthews Band', genre: 'Rock') }
context 'create_song using mocks' do
class MockSongGatewayService < SongGatewayService
def create_song(song)
song = song.clone
song.id = 'abc'
song.to_json
end
end
let(:gateway_service) { MockSongGatewayService.new }
it 'creates a valid song' do
new_song = subject.create_song(song)
expect(new_song.name).to eq song.name
expect(new_song.artist).to eq song.artist
expect(new_song.genre).to eq song.genre
expect(new_song.id).to_not be_nil
expect(new_song.id).to_not eq song.id
end
it 'raises a validation exception on required name field' do
song.name = nil
expect { subject.create_song(song) }.to raise_error
end
end
end

Testing with Doubles

RSpec provides a "double" interface that can stand in for any object in the test cycle.
RSpec.describe SongService do
require 'spec_helper'
subject { SongService.new(gateway_service: gateway_service) }
let(:song) { Song.new(name: 'Seek Up', artist: 'Dave Matthews Band', genre: 'Rock') }
context 'using doubles' do
let(:gateway_service) do
stub = double("gateway_service")
allow(stub).to receive(:create_song) do
Song.new(name: song.name, artist: song.artist, genre: song.genre, id: 'abc').to_json
end
stub
end
it 'creates a valid song' do
new_song = subject.create_song(song)
expect(new_song.name).to eq song.name
expect(new_song.artist).to eq song.artist
expect(new_song.genre).to eq song.genre
expect(new_song.id).to_not be_nil
expect(new_song.id).to_not eq song.id
end
end
end

Testing with Spies

RSpec also provides spies, a form of double where you can "spy" on the object interactions. This is my favorite of the three because you can verify how many times the doubled object method was called.
RSpec.describe SongService do
require 'spec_helper'
subject { SongService.new(gateway_service: gateway_service) }
let(:song) { Song.new(name: 'Seek Up', artist: 'Dave Matthews Band', genre: 'Rock') }
context 'using spies' do
let(:gateway_service) do
spy = spy("gateway_service")
allow(spy).to receive(:create_song) do
Song.new(name: song.name, artist: song.artist, genre: song.genre, id: 'abc').to_json
end
spy
end
it 'creates a valid song' do
new_song = subject.create_song(song)
# Here we can veirfy the gateway service was called correctly.
expect(gateway_service).to have_received(:create_song).with(song)
expect(new_song.name).to eq song.name
expect(new_song.artist).to eq song.artist
expect(new_song.genre).to eq song.genre
expect(new_song.id).to_not be_nil
expect(new_song.id).to_not eq song.id
end
end

Conclusion

Rails is a great framework, and the ruby community could learn from other web frameworks when enabling and optimizing for better testing. Constructor injection is one tool that may be useful in your testing toolbelt and in improving the structure and readability of your code.


Comments

Popular posts from this blog

Atmosphere Websockets & Comet with Spring MVC

Microservices Tech Stack with Spring and Vert.X