Skip to Content
Course content

56: Mocking and Stubbing in RSpec

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times with junior devs: you're building a feature that integrates with a third-party API—maybe Stripe for payments or Twilio for SMS—and you write a test that looks perfect. But the moment you run your test suite in CI, it fails. Why? Because the CI server doesn't have your local API keys, or the third-party sandbox is down, or you've hit your rate limit for the hour.

# The code we're testing
class OrderProcessor
  def initialize(payment_gateway = StripeGateway.new)
    @payment_gateway = payment_gateway
  end

  def process(order)
    response = @payment_gateway.charge(order.amount, order.currency)
    if response.success?
      order.mark_as_paid!
      return true
    end
    false
  end
end

# The "broken" test
RSpec.describe OrderProcessor do
  it "marks the order as paid when the charge is successful" do
    order = Order.new(amount: 100, currency: 'usd')
    processor = OrderProcessor.new
    
    # This actually hits the Stripe API!
    expect(processor.process(order)).to be true
    expect(order.paid?).to be true
  end
end

The Danger of Real API Calls in Tests

The test above is what I call a "brittle test." It's not actually testing your OrderProcessor logic; it's testing the network connection, the Stripe API's uptime, and your API credentials. If any of those fail, your test fails, even if your Ruby code is flawless. Plus, imagine if this were a production API—you'd be charging your own credit card every time you ran your tests. That's a quick way to go broke while trying to reach 100% test coverage.

Stubbing the Response for Predictability

To fix this, we use Stubbing. Stubbing is essentially telling RSpec: "When this specific method is called on this specific object, don't actually run the code inside that method. Instead, just return this value I'm giving you."

Here is how we isolate the OrderProcessor from the rest of the world:

RSpec.describe OrderProcessor do
  it "marks the order as paid when the charge is successful" do
    order = Order.new(amount: 100, currency: 'usd')
    
    # 1. Create a double (a fake object) for the gateway
    gateway = instance_double("StripeGateway")
    
    # 2. Stub the charge method to return a fake success response
    # We create a fake response object that responds to .success?
    success_response = double("Response", success?: true)
    allow(gateway).to receive(:charge).and_return(success_response)
    
    processor = OrderProcessor.new(gateway)
    
    expect(processor.process(order)).to be true
    expect(order.paid?).to be true
  end
end

By using instance_double, we've created a "test double." I prefer instance_double over a generic double because it verifies that the method we are stubbing (:charge) actually exists on the real StripeGateway class. If you rename the method in your actual code but forget to update the test, RSpec will throw an error. It prevents your mocks from becoming outdated.

Using Mocks to Ensure the Call Actually Happened

Stubbing is about providing input to your system. But sometimes, you need to verify output—not just the return value, but that a specific action was taken. This is where Mocking comes in. While stubbing says "If this is called, return X," mocking says "I expect this to be called exactly like this, or the test fails."

Let's say we want to make sure we are passing the correct amount to the gateway. We change allow to expect:

it "charges the correct amount to the gateway" do
  order = Order.new(amount: 50, currency: 'usd')
  gateway = instance_double("StripeGateway")
  success_response = double("Response", success?: true)

  # This is a mock. If .charge is NOT called with these exact args, the test fails.
  expect(gateway).to receive(:charge).with(50, 'usd').and_return(success_response)

  processor = OrderProcessor.new(gateway)
  processor.process(order)
end

A quick word of advice: don't over-mock. If you mock every single method call in your app, you're no longer testing your software; you're just testing that your code calls other code. Use stubs for external boundaries (APIs, Databases, File Systems) and mocks when the fact that a call happened is the primary goal of the test.




📋 Practical Task

Exercise: Mocking the Weather Alert System

You are building a WeatherNotifier class. It depends on a WeatherClient class that fetches the current temperature from a remote API. If the temperature is below 32 degrees, the notifier should send an alert via a SmsService.

Your Task: Write a test for the WeatherNotifier#check_and_notify method. You must:

  • Use instance_double to mock the WeatherClient so it returns a temperature of 25 degrees.
  • Use a mock to verify that the SmsService receives the method :send_alert with the message "Freeze warning!" exactly once.
  • Ensure that if the WeatherClient returns 75 degrees, the SmsService is not called.

Starter Code:

class WeatherNotifier
  def initialize(client, sms_service)
    @client = client
    @sms_service = sms_service
  end

  def check_and_notify(city)
    temp = @client.get_temperature(city)
    if temp < 32
      @sms_service.send_alert("Freeze warning!")
    end
  end
end

# You need to write the RSpec tests for this class.
# Assume WeatherClient has a method #get_temperature(city)
# Assume SmsService has a method #send_alert(message)
Rating
0 0

There are no comments for now.

to be the first to leave a comment.