The Three Musketeers: Double, Stub & Mock

The Three Musketeers: Double, Stub & Mock
Some fluffy pens at a store

Writing tests in Ruby can be truly enjoyable — but when the concepts aren’t clear in your mind, the experience quickly turns painful. Few things are more frustrating than a failing test, especially when you can’t immediately understand why it failed. I’ve been there many times myself. When the core ideas don’t fully click, it often leads to wasted time and lost motivation.

The idea for this article came to me while reading Eloquent Ruby. When I reached the chapter on testing, I realized that I understood the words, but the concepts of double, stub, and mock weren’t completely settling in my mind. After digging a little deeper, experimenting, and doing some research, I finally managed to connect the dots. This post is my attempt to explain these concepts — with practical Ruby examples — in a way that makes them easy to grasp.

Double

A Double is a fake object that stands in for a real one. The main purpose of using a double is to isolate your tests from external dependencies — such as APIs, databases, or email services — so that your tests remain fast, reliable, and free of side effects.

RSpec.describe "Test Double" do
  it "uses a simple double" do
    user = double("User")
    allow(user).to receive(:name).and_return("Ender")

    expect(user.name).to eq("Ender")
  end
end

In this example, we don’t need a real User model. The test passes because the double provides just enough behavior (name) to satisfy what we’re testing.

Stub

A Stub defines a fixed return value for a method.
In other words: “When this method is called, always return this specific result.”
Stubs don’t care whether the method was actually invoked; they simply control what happens if it is.

RSpec.describe "Stub" do
  it "stubs a method" do
    calculator = double("Calculator")
    allow(calculator).to receive(:add).with(2, 3).and_return(5)

    expect(calculator.add(2, 3)).to eq(5)
  end
end

Pros: Perfect for isolating things like API calls or database queries, making tests faster and more predictable.
Cons: A stubbed method might never be called, and the test would still pass. That means you could miss critical behavior.

Mock

A Mock works like a stub, but with an extra expectation: the method must actually be called during the test. If it isn’t, the test will fail.
In other words: “Call this method, and call it with these exact arguments.”

RSpec.describe "Mock" do
  it "expects a method call" do
    notifier = double("Notifier")
    expect(notifier).to receive(:send_email).with("ender@example.com")

    notifier.send_email("ender@example.com")
  end
end

Pros: Ensures that critical interactions (like sending an email or triggering a payment API) actually happen.
Cons: Overusing mocks can make tests brittle, since even small internal changes (e.g., calling a different logger method) can break the tests.

A Real-World Example: PaymentService

Let’s imagine a simple payment service. When a user makes a payment:

  1. The payment is processed.
  2. An email is sent to the user.
  3. The action is logged.
class PaymentService
  def initialize(notifier, logger)
    @notifier = notifier
    @logger = logger
  end

  def process(user, amount)
    @notifier.send_email(user.email, "Payment of #{amount} received")
    @logger.info("Payment of #{amount} processed for #{user.email}")
    true
  end
end

payment_service.rb

RSpec.describe PaymentService do
  let(:user) { double("User", email: "ender@example.com") }
  let(:notifier) { double("Notifier") }
  let(:logger) { double("Logger") }
  let(:service) { PaymentService.new(notifier, logger) }

  it "sends an email and logs the payment" do
    expect(notifier).to receive(:send_email).with("ender@example.com", "Payment of 100 received")
    allow(logger).to receive(:info).and_return(true)

    result = service.process(user, 100)

    expect(result).to eq(true)
  end
end

payment_service_spec.rb

  • Doubleuser, notifier, and logger are fake objects that replace real dependencies.
  • Stublogger.info doesn’t actually log anything, it just returns a fake value.
  • Mocknotifier.send_email must be called, or the test fails.

What Happens If We Use Them Incorrectly?

Only Stubs

allow(notifier).to receive(:send_email).and_return(true)

✅ The test passes.
❌ But even if no email is sent, the test still passes → missing critical validation.

Only Mocks

expect(logger).to receive(:info).with("Payment of 100 processed")

✅ Ensures logging happens.
❌ But even a small change (like switching info to warn) breaks the test. Overly fragile.

Balanced Approach (Best Practice)

Use Mocks for critical actions (e.g., email notifications, API calls). Use Stubs for less important side effects (e.g., logging, metrics). This balance ensures tests are both reliable and flexible.

Comparing Doubles, Stubs, and Mocks

Conclusion

Doubles, stubs, and mocks are like the Three Musketeers of Ruby testing: each has a unique role, and when used together wisely, they make your test suite cleaner, faster, and more reliable.

  • Doubles act as placeholders for real objects.
  • Stubs fix return values to isolate external dependencies.
  • Mocks ensure critical interactions actually happen.

The secret is balance. Mock what truly matters (emails, payments, API calls), stub what doesn’t (logging, metrics), and rely on doubles to keep your tests lightweight.

By understanding these three concepts clearly, you’ll not only reduce the frustration of mysterious test failures but also write tests that give you confidence in your code — without slowing you down. Now that you’ve seen how doubles, stubs, and mocks work, I’d love to hear from you:

Which of these concepts do you find yourself mixing up the most when writing tests?