Week 4 - Using more RSpec

Week 4 - Using more RSpec

·

13 min read

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