Testing doubles, Mocks, Stubs and Spies
describe Oystercard do
let(:entry_station) { double :station }
let(:exit_station) { double :station }
it 'stores exit_station' do
subject.top_up(30)
subject.touch_in(entry_station)
subject.touch_out(exit_station)
expect(subject.exit_station).to eq exit_station
end
end
# lib/oystercard.rb
def touch_in(station)
fail 'Please top up!' if @balance < MINIMUM
@entry_station = station
end
This creates two objects using a test double, one called entry_station and another called exit_station.
As we haven't created the Station class yet we can use a test double.
We can have a dummy class that behaves like a station object and our Oystercard class will not have to depend on the Station class. This way if a test fails, we will be able to know immediately that there is a bug in our Oystercard class and not some other class.
Example 2
class ClassRoom
def initialize(students)
@students = students
end
def list_student_names
@students.map(&:name).join(',')
end
end
This is a simple class, it has one method list_student_names
, which returns a comma delimited string of student names. Now, we want to create tests for this class but how do we do that if we haven’t created the Student class yet? We need a test Double.
Also, if we have a “dummy” class that behaves like a Student object then our ClassRoom tests will not depend on the Student class. We call this test isolation.
If our ClassRoom tests don’t rely on any other classes, then when a test fails, we can know immediately that there is a bug in our ClassRoom class and not some other class. Keep in mind that, in the real world, you may be building a class that needs to interact with another class written by someone else.
This is where RSpec Doubles (mocks) become useful. Our list_student_names method calls the name method on each Student object in its @students member variable. Therefore, we need a Double which implements a name method.
Here is the code for ClassRoom along with an RSpec Example (test), yet notice that there is no Student class defined −
describe ClassRoom do
it 'the list_student_names method should work correctly' do
student1 = double('student')
student2 = double('student')
allow(student1).to receive(:name) { 'John Smith'}
allow(student2).to receive(:name) { 'Jill Smith'}
cr = ClassRoom.new [student1,student2]
expect(cr.list_student_names).to eq('John Smith,Jill Smith')
end
end
As you can see, using a test double allows you to test your code even when it relies on a class that is undefined or unavailable. Also, this means that when there is a test failure, you can tell right away that it’s because of an issue in your class and not a class written by someone else.
Example 3
class User
def buy(book, quantity)
book.decrease_count_on_hand(quantity)
end
end
class Book
def decrease_count_on_hand(quantity)
API::Stock::Book.find(id).decrease_count_on_hand(quantity) # return true or false
end
end
While testing the buy
method on User we probably don’t want to actually call decrease_count_on_hand
on Book for several reasons:
- it could call an external API which is generally slow;
- it could be hard to configure the database with factories in order to make it work;
- it is not responsibility of a User unit test to ensure that this method works properly;
- this method could not have been implemented yet on the API;
Testing without a test double
context '#buy' do
let(:user) { User.create }
let(:book) { Book.create }
it 'returns true' do
expect(user.buy(book, 1)).to eq true
end
end
Disadvantage
We are just testing the return value of decrease_count_on_hand
method, which is slow and it is probably already tested elsewhere.
How to improve spec?
We can improve our spec by testing that decrease_count_on_hand
on Book is called, which is exactly what the buy
method on User
is doing.
Doubles
By defining a double we can create a fake object which we can define methods behavior on.
context '#buy' do
let(:user) { User.create }
let(:book) { double('fake book') }
it 'calls decrease_count_on_hand' do
allow(book).to receive(:decrease_count_on_hand).and_return(true)
expect(user.buy(book, 1)).to eq true
end
end
Stubs
context '#buy' do
let(:user) { User.create }
let(:book) { Book.create }
it 'returns true' do
allow(book).to receive(:decrease_count_on_hand).and_return(true)
expect(user.buy(book, 1)).to eq true
end
end
This way decrease_count_on_hand
is not actually called on Book. Anyway we can do better since we are creating a real Book instance without using it at all. Also, creating a Book instance could trigger API calls or other slow operations.
Mocks
Test Stubs help with inputs and Mock Objects help with outputs. A Test Stub is a fake thing you stick in there to trick your program into working properly under test. A Mock Object is a fake thing you stick in there to spy on your program in the cases where you’re not able to test something directly.
This is why we say that stubs are for testing state, while mocks are for testing behaviour. Stubs merely provide canned answers to method calls in the function. This isolates the object under test, preventing the return value from being influenced by any external objects (dependencies). They aren't concerned with how the method works (behaviour), but only with the result (state). Mocks are concerned with the "how". Mocks set expectations on which methods are called, how many times, and with which arguments. Stubs set no expectations. That is also why mocks can make tests fail and stubs cannot.
class User
def buy(book, quantity)
return true if book.ebook?
book.decrease_count_on_hand(quantity)
end
end
class Book
def decrease_count_on_hand(quantity)
API::Stock::Book.find(id).decrease_count_on_hand(quantity) # return true or false
end
end
context '#buy' do
let(:user) { User.create }
let(:book) { double('fake book') }
context 'when book is digital' do
before do
allow(book).to receive(:ebook?).and_return(true)
end
it 'does not call decrease_count_on_hand' do
expect(book).not_to receive(:decrease_count_on_hand)
user.buy(book, 1)
end
end
context 'when book is not digital' do
before do
allow(book).to receive(:ebook?).and_return(false)
end
it 'calls decrease_count_on_hand' do
expect(book).to receive(:decrease_count_on_hand).with(1).and_return(true)
user.buy(book, 1)
end
end
end
When we need to be sure a method is called we can mock it, which means stub it and set an expectation that it will be called. In this case we want to be sure decrease_count_on_hand is called on the book double only for some scenarios:
expect(book).not_to receive(:decrease_count_on_hand)
ensures that decrease_count_on_hand will not be called if book is an ebook
Spies
context '#buy' do
let(:user) { User.create }
let(:book) { spy('fake book') }
it 'calls decrease_count_on_hand' do
user.buy(book, 1)
expect(book).to have_received(:decrease_count_on_hand).with(1)
end
end
Mocking someway breaks the usual spec flows, that should be similar to the one described by Dan Croak in Four-Phase Test post. We are setting the expectation before executing the code that we want to verify. If we want to respect the “standard” flow we can take advantage of Spies:
Message expectations put an example's expectation at the start, before you've invoked the code-under-test. Many developers prefer using an arrange-act-assert (or given-when-then) pattern for structuring tests. Spies are an alternate type of test double that support this pattern by allowing you to expect that a message has been received after the fact, using have_received.
Refactoring RSpec
We can use the expect{} to expect changes to happen and use expect() for when checking a return value. We can use both expect{} and .to change to chain on testing which results in a clearer test message and the journey of the expectation of the result. Then the third test we have used the constant variable to reference the CHARGE value in the code.
it 'adds money onto the card' do
oystercard = Oystercard.new
oystercard.top_up(5)
expect(oystercard.balance).to eq 5
end
# TO
it 'adds money onto the card' do
oystercard = Oystercard.new
expect{ oystercard.top_up 5 }.to change{ oystercard.balance }.by 5
end
# TO
it 'deducts the correct amount from the balance' do
oystercard.top_up(40)
oystercard.touch_in
expect{ oystercard.touch_out }.to change{ oystercard.balance }.by (-Oystercard::CHARGE)
end
Refactor to use the constant variable instead of hard coding values
This way it is reusable and can easily amend the value of the limit in the class, and do not have to change the test as it would already reflect the same value as we have accessed the limit constant variable.
it 'raises an error if balance is more than £90' do
oystercard = Oystercard.new
expect{ oystercard.top_up(95) }.to raise_error('Maximum limit is £90')
end
# TO
it 'raises an error if balance is more than £90' do
oystercard = Oystercard.new
limit = Oystercard::LIMIT
oystercard.top_up(limit)
expect { oystercard.top_up 1}.to raise_error("Maximum limit of #{limit} exceeded")
end
Improve readability
Both testing same output that the in_journey is false. We can use the subject object to reference the oystercard and ensure in_journey is not true.
describe '#in_journey?' do
it 'tracks the status of the journey' do
expect(oystercard.in_journey).to eq false
end
end
# TO
describe '#in_journey?' do
it 'tracks the status of the journey' do
expect(subject).not_to be_in_journey
end
end
!! Operator
Nil and false will only be treated as false, so anything else even an empty '' will be true.
So in this instance even if entry_station is nil this will be treated as false so we can use the double !! operator to negate the truthness of its argument and convert into a boolean true/false outcome.
Inside touch_in method, the in_journey? will convert to true as entry_station is of a value of station. Inside touch_out method, the in_journey? will convert to false as entry_station is nil which is false.
def touch_in(station)
fail 'Please top up!' if @balance < MINIMUM
@entry_station = station
p in_journey? // true
end
def touch_out(exit_station = nil)
deduct(CHARGE)
@entry_station = nil
p in_journey? // false
end
def in_journey?
return true if @entry_station != nil
false
end
# SAME AS
def in_journey?
!!entry_station
end