RSpec: Mocking vs Stubbing
Most people learn RSpec by copying a line that works and moving on. That is how you end up with a test suite full of double calls where nobody remembers which of them actually assert something. Mocking and stubbing look almost identical in the editor, but they do opposite jobs: one replaces a value, the other sets an expectation. Get that distinction wrong and your green suite starts lying to you.
A stub answers a question. A mock checks that you asked it.
🧪 The Core Difference
Both mocking and stubbing are forms of test doubles, fake objects that stand in for the real thing so a unit test does not have to hit a database, a network, or a payment provider. The difference is what happens at the end of the test:
- Stubbing controls what a collaborator returns. Nothing is verified. If the code never calls the stubbed method, the test still passes.
- Mocking sets an expectation. If the call never happens, or happens with the wrong arguments, the test fails.
In RSpec both are built from the same machinery. allow creates a stub, expect(...).to receive creates a mock. That single word, allow versus expect, is the whole difference.
🎯 Stubbing: Replace the Return Value
Use a stub when the test does not care whether the method is called, only that the rest of the code can keep moving when it is. A typical stub says "when someone asks for this, hand back that."
allow(payment_gateway).to receive(:charge).and_return(true)
allow(clock).to receive(:now).and_return(Time.utc(2026, 4, 4))
Neither line asserts anything. If you deleted the call to charge from your production code, both specs would still pass. That is the point and the danger, all at once.
Stubbing in a real spec
RSpec.describe ReportBuilder do
it "uses the configured timezone" do
allow(Time).to receive(:now).and_return(Time.utc(2026, 4, 4, 9, 0))
report = ReportBuilder.new.build
expect(report.generated_on).to eq(Date.new(2026, 4, 4))
end
end
Here the stub is setup, not an assertion. The assertion is the eq on the last line.
✅ Mocking: Set an Expectation
Use a mock when the behavior under test is the call itself. Sending a receipt, publishing to a queue, charging a card: these are side effects you want to prove happened exactly once.
expect(mailer).to receive(:send_receipt).with(42).once
This one does assert. If send_receipt is never called, or is called with the wrong order id, or is called twice, the spec fails with a clear message. That is why mocks belong around interactions with the outside world.
Mixing both in one spec
RSpec.describe OrderCheckout do
let(:payment_gateway) { instance_double("PaymentGateway") }
let(:mailer) { instance_double("Mailer") }
let(:order) { double("Order", id: 42, total: 4999) }
it "charges the customer and emails a receipt" do
allow(payment_gateway).to receive(:charge).and_return(true)
expect(mailer).to receive(:send_receipt).with(42)
OrderCheckout.new(payment_gateway, mailer).call(order)
end
end
The gateway is stubbed because the test only needs it to stop throwing. The mailer is mocked because delivering the receipt is the behavior being checked. Same object type, deliberately different treatment.
⚖️ Side by Side
| Aspect | Stubbing | Mocking |
|---|---|---|
| RSpec syntax | allow(x).to receive(:m) |
expect(x).to receive(:m) |
| Purpose | Control a return value | Verify an interaction |
| Asserts anything? | No | Yes |
| Fails if the call never happens? | No | Yes |
| Test smells like | Setup | Assertion |
| Reach for it when | The collaborator is noise | The call is the point |
🧠When to Use Which
A simple rule holds up in practice: stub everything you do not want to think about, mock only the one thing the test is about.
- Stub expensive or slow collaborators, such as HTTP clients, clocks, and file systems.
- Stub random values so a test is deterministic.
- Mock side effects you must guarantee: emails, jobs, payments, cache writes.
- Keep mocks to one or two per spec. A test with six expectations is testing six things.
⚠️ Common Mistakes
Most brittle suites come from a few repeated slips.
- Mocking your own classes. Expectations on internal collaborators break the moment you refactor. Mock the boundary, not your own objects.
- Using a plain
doublefor a real interface. Preferinstance_doubleandclass_double. They fail when the method name or signature does not actually exist, which catches typos that a plain double happily accepts. - Stubbing a method that does not exist. By default RSpec raises, so treat that error as a bug in the spec, not an obstacle to silence with
verify_partial_doubles = false. - Asserting implementation details. If a mock asserts the exact order of three internal calls, every refactor will rewrite the test.
💡 TL;DR
Stubbing is for controlling inputs, mocking is for verifying outputs. Use allow when you just need a fake value back, and expect(...).to receive when the interaction itself is what you are testing. When in doubt, stub more and mock less.
🧩 Conclusion
Mocking and stubbing are not competing techniques, they are two tools with distinct jobs. A stub keeps the rest of your code out of the way so a unit can be tested in isolation. A mock makes a promise about how that unit talks to the world. Draw the line at the boundary of your system, verify the interactions that matter, and let everything else be a quiet, boring stub.
Comments
Post a Comment