Ruby | Rspec | Mocks & Stubs
I struggled with the concept of Mock vs. Stub for a little while and it wasn’t until I was in a paired programming session with Sergii Makagon that it finally clicked for me. Seeing the process of adding mocks and stubs evolve during a refactoring session helped me understand the two concepts, so I wanted to try and explain mocks and stubs in a similarly iterative way.
Vocabulary:
Double: It’s a dumb object we create that stands-in for an object we need in order to test something.
Stub: Stubs allow an object to receive messages/methods. Usually they’re Doubles but we can allow
‘real’ objects to receive messages/methods too.
Mock: Objects with expectations. Key word being expect
.
Why do we need Doubles?
Doubles are cool because sometimes classes rely on other objects in order to work. Doubles make it easy to test a class’s methods without having to instantiate objects. In unit testing, we try to be as lazy as possible. We want to instantiate the bare minimum to get a test to pass. We should never care about anything else except that method we’re testing at that time.
Why do we need Stubs?
Sometimes we rely not only on objects but those objects’ methods/messages too. We can use stubs to mimic those methods/messages.
Why do we need Mocks?
Sometimes a test expects certain messages from objects. We can add these messages at runtime for that test. We can expect a method response from a mock to fulfill the tested method’s needs.
Some quick boilerplate code for context:
The code below implements a simple example of dependency injection.
user.rb:
class User
attr_reader :email, :first_name, :last_name, :phone_number
def initialize(email:, first_name:, last_name:, phone_number:)
@email = email
@first_name = first_name
@last_name = last_name
@phone_number = phone_number
end
end
identity_provider.rb (like a class interface)
class IdentityProvider
def self.authenticate(_email)
‘The self.authenticate method has not been defined for this Identity Provider 🙊’
end
end
twitter_idp.rb
class TwitterIdp < IdentityProvider
def self.authenticate(email)
“Authenitcated 🔓 using #{name.split(‘::’).last} with email:
#{email}”
end
end
salesforce_idp.rb
class SalesforceIdp < IdentityProvider
def self.authenticate(email)
“Authenitcated 🔓 using #{name.split(‘::’).last} with email: #{email}”
end
end
facebook_idp.rb
class FacebookIdp < IdentityProvider; end
session.rb
require_relative 'user'
require_relative 'identity_provider'
class Session
attr_reader :current_user
def initialize(user)
@current_user = user
end def create(idp = SalesforceIdp)
idp.authenticate(current_user.email)
end
end
run_idp.rb
require_relative ‘session’
session = Session.new(
User.new(
email: ‘foobar@gmail.com’,
first_name: ‘Derek’,
last_name: ‘Dyer’,
phone_number: ‘5555555555’
)
)session.create(TwitterIdp)
session.create(FacebookIdp)
session.create
Double
I want to test Session#create, so I made a file called session_spec.rb with an empty test in it.
require_relative 'session'
RSpec.describe Session do it 'creates a new session' do
end
end
We set ‘session’ to equal the class we are testing: Session. Looking at the ‘session.rb’ file, Session needs a User object in order for Session to be initialized. Instantiating the User class is totally an option:
require_relative 'session'
RSpec.describe Session do let(:current_user) do
User.new(
email: 'foobar@gmail.com',
first_name: 'Derek',
last_name: 'Dyer',
phone_number: '5555555555'
)
end let(:session) { described_class.new(current_user) } describe '#create' do
it 'creates a new session' do
end
end
end
We’re instantiating the User class for what? To test the Session#create method. Let’s look at what that method needs from User to run:
In the session.rb file:
def create(idp = SalesforceIdp)
idp.authenticate(current_user.email)
end
It needs an instance of the User class (it also needs an email from User but we’ll get to that later).
let(current_user) { double('user')
That 👆 is a double using the Rspec double
method. I like to think of it as a stunt double. Let’s run it with a simple test.
require_relative ‘session’
RSpec.describe Session do let(:current_user) { double(‘user’) } let(:session) { described_class.new(current_user) } describe ‘#create’ do
it ‘creates a new session’ do
expect(session.create).to eq(
“Authenticated 🔓 using SalesforceIdp with email: #{current_user.email}”
)
end
end
#Double “current_user”> received unexpected message :email with (no args)
Ok, we’re getting somewhere. When we ran the test for Session#create we got the above error. We need the current_user’s email. We have a few options.
Stub
Definition of stub:
A method stub is an instruction to an object (real or test double) to return a
known value in response to a message.
That sounds useful. Let’s try it with: allow(current_user).to receive(:email)
require_relative 'session'
RSpec.describe Session dolet(:current_user) { double('current_user' }let(:session) { described_class.new(current_user) }describe '#create' do
it 'creates a new session' do
allow(current_user).to receive(:email) expect(session.create).to eq(
"Authenitcated 🔓 using SalesforceIdp with email: #{current_user.email}"
)
end
end
end
It worked! We instructed the user object to respond to email and our test is passing. Also, notice how much cleaner that is than if we instantiated a User object. If we were relying on current_user.email
to respond with something specifically, we could have done this:
allow(current_user).to receive(:email) { 'email@email.com' }
As it turns out, the Session#create method doesn’t really care what current_user.email
returned, as long as it responded to the method.
FYI, we can also stub methods for real objects too. Stubs are not just applicable to mocks.
Mock
Remember we said a mock was like a stub but with an expectation? Let’s see what that looks like.
require_relative 'session'
RSpec.describe Session dolet(:current_user) { double('current_user' }let(:session) { described_class.new(current_user) }describe '#create' do
it 'creates a new session' do
expect(current_user).to receive(:email) { 'email@email.com' } expect(session.create).to eq(
"Authenticated 🔓 using SalesforceIdp with email: email@email.com"
)
end
end
end
This is better, it is more specific. It’s verifying that the session.create
method is calling the method the way it’s supposed to.
Verifying doubles (Bonus Round)
Most of the time you will want some confidence that your doubles resemble an existing object in your system. Verifying doubles are provided for this purpose. If the existing object is available, they will prevent you from adding stubs and expectations for methods that do not exist or that have invalid arguments.
Verifying doubles have some clever tricks to enable you to both test in isolation without your dependencies loaded while still being able to validate them against real objects.
https://relishapp.com/rspec/rspec-mocks/docs
We have a nice test. Let’s try a verifying double like this:
let(current_user) do
instance_double('User', email: 'email@email.com')
end
session_spec.rb
require_relative 'session'RSpec.describe Session dolet(:current_user) do
instance_double('User', email: 'foo@foo.com')
endlet(:session) { described_class.new(current_user) } describe '#create' do
it 'creates a new session' do
expect(session.create).to eq(
"Authenitcated 🔓 using SalesforceIdp with email: #{current_user.email}"
)
end
end
The cool thing about verifying doubles is they prevent us from adding stubs for methods that do not exist in the actual class or that have invalid arguments. So by changing user
to User
, we are now mocking with confidence because the verifying double is checking to see if an instance method exists AND we’re testing in isolation without instantiating a class AND we’re stubbing.
Thanks for your time!