Simplify testing with mocks and stubs
@h6165
Abhishek Yadav
ரூபீ ப்ரோக்ராமர்
Co-organizer: Chennai.rb
The background
Previous talk: Getting started with testing using rspec:
https://speakerdeck.com/h6165/lets-start-testing
Covers the premise and fundamentals.
The need
- Simulating difficult behavior
- Simplifying dependent objects
=> Make testing simpler, easier and fun
Simulating difficult behavior
Sometimes we have code
whose actual execution in the tests is costly/difficult
but not important to the outcomes of the test
Simulating difficult behavior
# Method under test:
# (within User model)
def complete_registration
self.state = 'registered'
self.save
UserMailer.registered(self).deliver!
end
# In this test case, we are only checking the value of state
# We can have a separate test for email delivery,
# but in this one, we are not concerned with the email part
it 'should mark the state as registered' do
user.complete_registration
expect(user.state).to eq('registered')
end
Simulating difficult behavior
Sometimes it is more complicated than a simple email:
- A complex query involving several models
- A slow step (like external API calls)
- A cost ($) sensitive step (API call involving a paid service )
Simplifying dependent objects
Our code could be dependant on objects of other types (class/model).
While writing tests, we have to make sure these objects are created and configured correct to have the desired outcome
Simplifying dependent objects
# Summary of comments, as they show on blog sidebars
# The summary contains the user's name,
# and the first twenty characters of the comment text
#
# Within Comment model,
# Comment has a column 'body' and
# Comment belongs_to :user
#
def comment_summary
user.name + "-" +
self.body[0..20]
end
# The method is dependent on the user object.
# While writing tests, we will need to create the user object too
# This may not seem like a big deal here,
# but when there are too many objects,
# or when object creation is complicated, it becomes important
Definitions
Test double | Object that stands for the real object. Much like 'stunt double'. |
Stub | Object with a method overridden to return fake value |
Mock | Object with a method overridden to return fake value, and the method must be called |
Spy | Test double + testable expectation later |
Note:
- Definitions are not standardised, so this as a general idea at best
- There are partial doubles and full doubles too. Partial double is more like stubs here.
Libraries
- rspec-mocks
- mocha
- rr
- flexmock
Most popular: rspec-mocks, mocha
Ref: https://www.ruby-toolbox.com/categories/mocking
Stub example
# Method under test: (extracted email sending to separate method)
def send_email
UserMailer.registered(self).deliver!
end
def complete_registration
self.state = 'registered'
self.save
send_email
end
# Stubbing the email step
it 'should mark the state as registered' do
allow(user).to receive(:send_email)
user.complete_registration
expect(user.state).to eq('registered')
end
Mock example
# Method under test: (extracted email sending to separate method)
def send_email
UserMailer.registered(self).deliver!
end
def complete_registration
self.state = 'registered'
self.save
send_email
end
# We want to make sure 'send_email' is invoked,
# even if email is not actually sent
# This test fails if we remove the 'send_email' call from above code
it 'should try sending the email' do
expect(user).to receive(:send_email)
user.complete_registration
end
Spy example
# Method under test: (extracted email sending to separate method)
def send_email
UserMailer.registered(self).deliver!
end
def complete_registration
self.state = 'registered'
self.save
send_email
end
# We want to make sure 'send_email' is invoked,
# even if email is not actually sent
# This test fails if we remove the 'send_email' call from above code
it 'should try sending the email' do
allow(user).to receive(:send_email)
user.complete_registration
expect(user).to have_received(:send_email)
end
Double example
# Within Comment model,
# Comment has a column 'body' and
# Comment belongs_to :user
def comment_summary
user.name + "-" +
self.body[0..20]
end
## Test
it "should be *Abhishek-This is the best*" do
# Instead of creating a real User object,
# which may be difficult, we create a double with just the name
user = double(name: 'Abhishek')
comment = Comment.new(body: 'This is the best')
expect(comment.comment_summary).to eq('Abhishek-This is the best')
end
Test complex code with stubs
- Starting tests on legacy code is difficult. Without tests/TDD, methods tend to get longer, with multiple responsibilities and dependencies entangled.
- We try testing such methods one behaviour at a time. But while doing so, data relevant to all other parts must be created. And behaviour for other parts be satisfied
- That makes writing tests difficult and slow.
- Stubbing helps approaching such a codebase. The idea is: stub all the data/behaviour that is not relevant to the current test-case. Test various parts as separate test-cases
Test complex code with stubs: example
class Partner < ActiveRecord::Base
## Create Location record, geocode it, and associate it
def update_locations(country, state, city)
if !country.blank?
l = Location.where(city: city, state: state, country: country).last
l = Location.new(city: city, state: state, country: country)
if l.latitude.nil? || l.longitude.nil?
lat, lng = Geocoder.coordinates("#{country}, #{state}, #{city}")
if lat && lng
l.latitude = lat
l.longitude = lng
end
end
l.save!
# Associate the Location with partner
pl = PartnerLocation.where(partner_id: self.id, location_id: l.id).last
if pl.nil?
pl = PartnerLocation.new(partner_id: self.id, location_id: l.id)
pl.save!
end
end
end
end
Test complex code with stubs: example
# Test case: 1
# Location does not exist, Geocoding is possible
it "test-1" do
partner = Partner.new
allow(partner).to receive(:id).and_return(1)
allow(Geocoder).to receive(:coordinates).and_return([13, 83])
partner.update_locations('India', 'Tamil Nadu', 'Chennai')
location = Location.last
expect([location.latitude, location.longitude]).to eq([13, 83])
end
Here we stub the Geocoder to return fake values we can use.
We also stub the partner#id to avoid saving it (it is used in the last part of the code)
Test complex code with stubs: example
# Test case: 2
# Location exists, Geocoding should not be invoked
it "test-2" do
partner = Partner.create
Location.create(country: 'India', state: 'Tamil Nadu', city: 'Chennai')
expect(Geocoder).to_not receive(:coordinates)
partner.update_locations('India', 'Tamil Nadu', 'Chennai')
end
Here we mock the Geocoder to make sure it is not called.
Too much mocking
- Too much mocking is not good because its hard to follow the code. Its hard to differentiate what is tested and what is mocked.
- If creating test-doubles starts taking too much effort, its better to create real objects
Thats all.
Thanks
Simplify testing with mocks and stubs
By Abhishek Yadav
Simplify testing with mocks and stubs
- 1,128