Home > Software design >  Rspec mocks and stubs confuse with expect
Rspec mocks and stubs confuse with expect

Time:11-22

I have confuse when use mocks and stubs in rspec on rails. I have test like below

require 'rails_helper'

class Payment
  attr_accessor :total_cents

  def initialize(payment_gateway, logger)
    @payment_gateway = payment_gateway
    @logger = logger
  end

  def save
    response = @payment_gateway.charge(total_cents)
    @logger.record_payment(response[:payment_id])
  end
end

class PaymentGateway
  def charge(total_cents)
    puts "THIS HITS THE PRODUCTION API AND ALTERS PRODUCTION DATA. THAT'S BAD!"

    { payment_id: rand(1000) }
  end
end

class LoggerA
  def record_payment(payment_id)
    puts "Payment id: #{payment_id}"
  end
end

describe Payment do
  it 'records the payment' do
    payment_gateway = double()
    allow(payment_gateway).to receive(:charge).and_return(payment_id: 1234)
    logger = double('LoggerA')
    expect(logger).to receive(:record_payment).with(1234)
    payment = Payment.new(payment_gateway, logger)
    payment.total_cents = 1800
    payment.save
  end
end

Ok when I run rspec it works, no problem, but when I try to move expect to last line like below:

    payment = Payment.new(payment_gateway, logger)
    payment.total_cents = 1800
    payment.save

    expect(logger).to receive(:record_payment).with(1234)

and I try to run rpsec, it fail, I dont know why expect is last line will fail, I thought that expect always puts in last line before we run something to get result to test. Anyone can explain for me ?

CodePudding user response:

expect(sth).to receive sets a message expectation which is to be satisfied between the call and end of the test, and that expectation is verified after the test finishes. When you move the expect to the last line, expectation is set just at the end of the test and no code is executed to satisfy it so it fails. Unfortunately it means breaking the prepare-execute-test order.

Which is why you should really rarely use expect.to receive and replace it with allow.to receive with expect.to have_received

# prepare
allow(logger).to receive(:record_payment)

# execute 
..

# test
expect(logger).to have_received(:record_payment).with(1234)

allow.to receive sets up a mock proxy which starts tracing received messages which then can be explicitly verified by expect.to have_received. Some objects automatically sets their mock proxies, for example you don't need allow.to receive for doubles with predefined responses or spies. In your case, you could write the test like:

payment_gateway = double
allow(payment_gateway).to receive(:charge).and_return(payment_id: 1234)
logger = double('LoggerA', record_payment: nil)
payment = Payment.new(payment_gateway, logger)
payment.total_cents = 1800
payment.save

expect(logger).to have_received(:record_payment).with(1234)

Other notes

I strongly recommend using verifiable_doubles, which will protect you from false positives:

payment_gateway = instance_double(PaymentGateway)
allow(payment_gateway).to receive(:charge).and_return(payment_id: 1234)

This test will now raise an exception if there is no charge method defined on PaymentGateway class - protecting you from your tests passing even in case you rename that method but forgot to rename it in the test and implementation.

  • Related