Testing and mocking the contents of a block with RSpec

1.3k views Asked by At

I am writing a unit test for one of my service objects. In this particular case, I needed to use transactions to ensure data integrity. Thus, I have a simple code like so:

class CreateUser
  def save
    user_klass.db.transaction do
      user = user_klass.create(name: name, email: email)
      another_model_klass.find_or_create(user_id: user.id, foo: 'foo')
    end
  end
end

I am using Sequel as my ORM. However, the important point of this question is actually how to test this code. I have been successfully using mocks and stubs but this is the first time I have to stub out something with a block involved.

At first I have a naive spec like so:

    describe CreateUser do
      describe "#save" do
        let(:user)                { instance_double("User", id: 1) }
        let(:user_klass)          { double("Class:User", create: user) }
        let(:another_model_klass) { double("Class:AnotherModel") }
        let(:name)                { 'Test User' }
        let(:email)               { '[email protected]' }
        let(:foo)                 { 'foo' }
        let(:params)              { { name: name, email: email, foo: foo } }
        let!(:form)               { CreateUser.new(params, user_klass, another_model_klass) }

        before do
          allow(another_model_klass).to receive(:find_or_create)
        end

        it "sends create message to the user_klass" do
          expect(user_klass).to receive(:create).with({ name: name, email: email}).and_return(user)
          form.save
        end

        it "sends find_or_create message to another_model_klass" do
          expect(another_model_klass).to receive(:find_or_create).with(user_id: user.id, foo: foo)
          form.save
        end

      end
    end

This gives out an error:

Double "Class:User" received unexpected message :db with (no args)

But if I add the following:

allow(user_klass).to receive_message_chain(:db, :transaction)

It would stub out the contents of the transaction block and it would still fail.

How do set expectations on my spec where:

  • expect transaction to be used
  • expect the create message to be sent to user_klass
  • expect the find_or_create message to another_model_klass
2

There are 2 answers

1
Ollie On

Take a look at spies https://github.com/rspec/rspec-mocks#test-spies, you might be able to drop them in your doubles. :-)

1
Myron Marston On

You can do this:

let(:db) { double("DB") }
let(:user_klass) { double("Class:User", create: user, db: db) }
# ...

before do
  allow(db).to receive(:transaction).and_yield
  # ...
end

That said: you can do that, but I recommend you don't. In fact, I recommend you don't mock the Sequel API at all. I can speak from experience that down the road of mocking APIs you don't own lies brittle, low-value tests and lots of pain. The general approach that I (and many others) recommend is to wrap the API you don't own with your own API that you do own. Then you can integration test your wrapper (without doing mocking or stubbing) and mock or stub your simpler, domain-specific API in all the other places that rely on that functionality.

On a side note, if you're using RSpec 3, I highly recommend you switch your test doubles to verifying doubles as they provide some really nice guarantees that normal doubles don't provide.