I was invited to do a short speech to students at the university where I once studied. It was a recruitment event that the company I work for promoted themselves. I had about 20 minutes and I wanted to make it 20 good minutes and teach something that is useful and that I didn't learn at the university. I decided to give a brief introduction to mocking in testing, more specific in Ruby on Rails, using RSpec and WebMock.
If you have an API client, a service using it and a test for testing the service, like this:
class PlaceholderClient
include HTTParty
base_uri 'https://jsonplaceholder.typicode.com'
def get_photo(id)
response = self.class.get("/photos/#{id}")
JSON.parse(response.body)
end
end
class PhotoService
def create_photo(photo_id)
photo_hash = PlaceholderClient.new.get_photo(photo_id)
photo = Photo.new(photo_hash.slice('title', 'url'))
photo.save!
photo
end
end
RSpec.describe PhotoService do
it 'creates a photo' do
service = PhotoService.new
photo = service.create_photo(1)
expect(photo).not_to be_nil
expect(photo.title).not_to be_nil
expect(photo.url).to be_url
end
end
The test will test the service just fine, and it will test the client and it will test the internet connection and it will test the API too... This is not what we want. Tests should be isolated and test one thing at a time. To fix this, we introduce mocking. The service is rewritten to accept a client as a parameter.
class PhotoService
def initialize(client = PlaceholderClient.new)
@client = client
end
def create_photo(photo_id)
photo_hash = @client.get_photo(photo_id)
photo = Photo.new(photo_hash.slice('title', 'url'))
photo.save!
photo
end
end
The test is then changed into injecting a client to the service, but not any client, a mocked client. This new mocked client is suppose to be called once with the method name get_photo and it will then return the same data as the real client. If it's not called, the test will fail. Now this test is isolated to test just the service, not anything else.
it 'should create a photo' do
client = double
allow(client).to receive(:get_photo)
.with(kind_of(Numeric))
.and_return({ title: 'An image', url: 'http://www.example.com/image.png'}.stringify_keys!)
service = PhotoService.new(client)
photo = service.create_photo(1)
expect(photo).not_to be_nil
expect(photo.title).to eq('An image')
expect(photo.url).to eq('http://www.example.com/image.png')
end
But if we actually want to test the actual PlaceholderClient, not the service, how would we go about, you ask. A test of the client could look like the following:
require 'rails_helper'
RSpec.describe PlaceholderClient do
it 'should fetch photo' do
json = PlaceholderClient.new.get_photo(1)
expect(json['title']).not_to be_nil
expect(json['url']).to be_url
end
end
But here we are again, your internet connection fails and this test fails, or the API is temporary unavailable and the test fails. This is not what we wanted. However, there a solution for that too. Enter WebMock. https://github.com/bblimke/webmock
Configure WebMock to return predefined data on certain http calls in the spec_helper.rb file. The following lines will make all get requests to any url that matches the pattern /jsonplaceholder.typicode.com/ to return a body with a title and a url in json format. Great! WebMock will disable all HTTP calls on default, so we don't have to do any changes to the test above. It will now not rely on your internet connection or other things.
config.before(:each) do
stub_request(:get, /jsonplaceholder.typicode.com/)
.to_return(status: 200,
body: {
title: 'An image',
url: 'http://www.example.com/image.png'
}.to_json)
end
It is, however, a good habit to test the actual things sometimes, and this is called integration test. The following code shows a nearly identical test to the one above, but this is tagged as an integration test and it will disable WebMock, that means enable http connections.
RSpec.describe PlaceholderClient, :integration do
before(:each) do
WebMock.disable!
end
it 'should fetch photo' do
json = PlaceholderClient.new.get_photo(1)
expect(json['title']).not_to be_nil
expect(json['url']).to be_url
end
end