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