Skip to content

Instantly share code, notes, and snippets.

@davetron5000
Created January 5, 2023 16:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save davetron5000/c7d22be3edc991c2eab32190b8783fc0 to your computer and use it in GitHub Desktop.
Save davetron5000/c7d22be3edc991c2eab32190b8783fc0 to your computer and use it in GitHub Desktop.

RSpec mocks let you control the behavior of classes to make it easier to test a class that uses those other classes (called dependences)

Suppose we have this routine:

class Purchase
  def purchase(customer, product)
    payments = Payments.new
    order = Order.create!(customer: customer, product: product)
    result = payments.charge(customer.payment_method, order.cost)
    if result.declined?
      DeclineMailer.email(order)
    else
      FulfillOrder.new(order).fulfill!
    end
  end
end

Don't worry about how great or not this code is. Sometimes things are complicated.

Testing this is gonna be rough because Payments charges credit cards, DeclineMailer sends email and who knows what FulfillOrder does?

But we need to check that we made an order and handled the decline properly to know if purchase is working as intended.

We can use rspec to mock the behavior of our complicated dependencies Payments, DeclineMailer, and FulfillOrder.

First, let's handle the case of successfully charging. We want to check that our order was created and that FullfillOrder's fulfill! method is called.

To make sure that happens, we need a verison of Payments whose charge method returns a result representing a successful charge.

RSpec.describe Purchase do
  subject(:purchase) { described_class.new }

  describe "#purchase" do
    context "card charges successfully" do
      it "fulfills the order" do
        customer = create(:customer)
        product  = create(:product)

        # TODO: Configure Payments#charge to return success

        purchase.purchase(customer,product)

        expect(customer.orders.size).to eq(1)
        expect(customer.orders.product).to eq(product)

        # TODO: assert that FullfillOrder#fulfill! was called

      end
    end
  end
end

Using RSpec's mocking library, we handle both our TODOs in largerly the same way:

  • we mock the constructor of each class to return a mock object so when Purchase calls new on them, it gets our mock
  • we configure that mock object to allow certain methods to be called
  • we check the mock objects to see what happened ( at least for those that we care about )

We'll use instance_double to create the mock object. This makes a mock that won't respond to methods the underlying class won't respond to. We'll then use allow to configure what happens when .new is called. allow will also be used to configure what happens with we call charge on our Payments mock:

[ ed note - if I were writing a book, I would do this in smaller steps ]

  RSpec.describe Purchase do
    subject(:purchase) { described_class.new }

    describe "#purchase" do
      context "card charges successfully" do
        it "fulfills the order" do
          customer = create(:customer)
          product  = create(:product)
          
         payments      = instance_double(Payments)
         fulfill_order = instance_double(FullfillOrder)

         allow(Payments).to receive(:new).and_return(payments)
         allow(payments).to receive(:charge).and_return(OpenStruct.new(:declined?: false))

         allow(FullfillOrder).to receive(:new).and_return(fulfill_order)
         allow(fulfill_order).to receive(:fulfill_order)

          purchase.purchase(customer,product)

          expect(customer.orders.size).to eq(1)
          order = customer.orders.first
          expect(customer.orders.product).to eq(product)

          # TODO: assert that FullfillOrder#fulfill! was called

        end
      end
    end
  end

Now, when purchase.purchase is called, when charge is called, our mock payments will respond and return an object that responds false to declined?

That will call fulfill_order on our FullfillOrder mock. We just need to make sure that happened, as that is the point of this test. We do that by expecting the mock to have_received the method call:

  RSpec.describe Purchase do
    subject(:purchase) { described_class.new }

    describe "#purchase" do
      context "card charges successfully" do
        it "fulfills the order" do
          customer = create(:customer)
          product  = create(:product)
          
          payments      = instance_double(Payments)
          fulfill_order = instance_double(FullfillOrder)

          allow(Payments).to receive(:new).and_return(payments)
          allow(payments).to receive(:charge).and_return(OpenStruct.new(:declined?: false))

          allow(FullfillOrder).to receive(:new).and_return(fulfill_order)
          allow(fulfill_order).to receive(:fulfill_order)

          purchase.purchase(customer,product)

          expect(customer.orders.size).to eq(1)
          order = customer.orders.first
          expect(order.product).to eq(product)

         allow(fulfill_order).to have_received(:fulfill_order).with(order)

        end
      end
    end
  end

Awesome! To check the other case, we change and_return(OpenStruct... to return :declined? true and then assert that our mailer was called.

@esparta
Copy link

esparta commented Jan 5, 2023

This is great!

I'd double down in use a rspec's double instead of OpenStruct to remark to the maintainers that is not the real object but an rspec artifact - of course, there's nothing wrong on use one or the other.

- allow(payments).to receive(:charge).and_return(OpenStruct.new(:declined?: false))
+ allow(payments).to receive(:charge).and_return(double(declined?: false))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment