Mocking #
Mocking is the art of replacing real dependencies with controllable stand-ins during testing. Without mocking, tests that send real emails, call paid APIs, or wait on large databases become slow, expensive, and impossible to run offline. With mocking, you can test how your code reacts to various responses from dependencies — including cases that are hard to provoke in the real world like network timeouts, 500 responses from servers, or a full database — without actually needing those conditions. This article covers all mocking techniques in the Ruby ecosystem: from double and allow in RSpec to WebMock for HTTP and VCR for recording and replaying real requests.
Terminology — Mock, Stub, Spy, Fake #
These terms are often used interchangeably but have distinct meanings:
Stub:
Replaces a method with an implementation that returns a specific value.
Purpose: control the input for the code being tested.
Doesn't verify whether the method was called.
Example: allow(user).to receive(:email).and_return("[email protected]")
Mock:
Like a stub, BUT also verifies that the method is called
with specific arguments, how many times, in a specific order.
Purpose: verify interactions between objects.
Example: expect(mailer).to receive(:send).once.with("[email protected]")
Spy:
An object that records all interactions without blocking anything.
Verification happens after the code executes (not before).
Purpose: observe without changing behavior.
Example: spy = spy("Logger"); ...; expect(spy).to have_received(:log)
Fake:
An alternative implementation that works but is simplified.
Purpose: a lightweight replacement for a heavy dependency.
Example: an in-memory repository replacing a real database
flowchart LR
A[Test] --> B[Code Under Test]
B --> C{Dependency}
C --> D[Stub\nControls return values]
C --> E[Mock\nVerifies interactions]
C --> F[Spy\nPassive observation]
C --> G[Fake\nAlternative implementation]
D --> H[Real Email Service\nreplaced]
E --> H
F --> H
G --> HRSpec Mocks — double, allow, expect #
double — Fake Objects #
double creates a fake object that has no methods except those you define:
RSpec.describe EmailDelivery do
let(:mailer) { double("Mailer") }
it "sends a welcome email" do
# Define the methods allowed on the double
allow(mailer).to receive(:send).and_return(true)
service = EmailDelivery.new(mailer)
service.welcome("[email protected]")
# Verify the method was called
expect(mailer).to have_received(:send)
.with("[email protected]", subject: "Welcome!")
end
end
# double with direct return values (shorthand)
mailer = double("Mailer", send: true, status: :active)
# A strict double — raises an error if a method isn't defined
mailer = double("Mailer")
mailer.send # => RSpec::Mocks::MockExpectationError: Method not allowed
instance_double — Contract Verification #
instance_double is a stricter version of double — it verifies that the stubbed method actually exists on the real class and that its signature matches:
class EmailService
def send(to, subject:, body:)
# real implementation
end
end
RSpec.describe NotificationDispatcher do
# instance_double ensures :send actually exists on EmailService
# and accepts the same arguments
let(:mailer) { instance_double(EmailService) }
it "calls the email service correctly" do
allow(mailer).to receive(:send).and_return(true)
notification = NotificationDispatcher.new(mailer)
notification.send_confirmation("[email protected]")
expect(mailer).to have_received(:send).with(
"[email protected]",
subject: "Order Confirmation",
body: anything
)
end
end
# If EmailService doesn't have a :send method, or the signature differs:
# RSpec errors immediately — preventing tests passing while production code breaks!
# The equivalent for classes (class methods):
class_double(RealClass)
# The equivalent for already-created objects:
object_double(real_object)
allow vs expect — Stub vs Mock #
# allow — stub: define the return value, don't verify whether it's called
allow(object).to receive(:method).and_return(value)
# The test passes even if the method is never called
# expect — mock: define the return value AND verify it's called
expect(object).to receive(:method).and_return(value)
# The test fails if the method isn't called
# Order within a test:
# ANTI-PATTERN: expect after the code has executed (spy pattern is more appropriate)
object.do_something
expect(object).to receive(:method) # too late!
# CORRECT: expect before the code that triggers it
expect(object).to receive(:method).and_return(value)
object.do_something # this triggers the method call
# Or use spy + have_received for post-fact verification
allow(object).to receive(:method)
object.do_something
expect(object).to have_received(:method) # verify after execution
Argument Matchers #
Argument matchers let you specify how strictly arguments should be matched:
# Exact value matching
expect(mailer).to receive(:send).with("[email protected]", "Hello")
# anything — any argument in that position
expect(db).to receive(:save).with(anything)
# any_args — any arguments at all
expect(logger).to receive(:log).with(any_args)
# no_args — called without arguments
expect(cache).to receive(:delete).with(no_args)
# Type matching
expect(parser).to receive(:parse).with(instance_of(String))
expect(handler).to receive(:handle).with(kind_of(StandardError))
# Hash matchers
expect(api).to receive(:post).with(hash_including(email: "[email protected]"))
expect(api).to receive(:post).with(hash_excluding(:password))
# Array matchers
expect(notif).to receive(:send_to).with(array_including("user1", "user2"))
# Regex
expect(logger).to receive(:info).with(/success/)
# Combination with custom conditions
expect(validator).to receive(:check).with(
satisfy { |val| val.length > 3 && val.include?("@") }
)
# Nested matching
expect(api).to receive(:request).with(
hash_including(
headers: hash_including("Authorization" => /^Bearer /),
body: hash_including(user_id: instance_of(Integer))
)
)
Message Expectations — Frequency and Order #
# How many times a method is called
expect(mailer).to receive(:send).once # exactly 1x
expect(mailer).to receive(:send).twice # exactly 2x
expect(mailer).to receive(:send).exactly(3).times
expect(mailer).to receive(:send).at_least(:once)
expect(mailer).to receive(:send).at_least(2).times
expect(mailer).to receive(:send).at_most(3).times
expect(mailer).not_to receive(:send) # must never be called
# Call order
expect(db).to receive(:begin_transaction).ordered
expect(db).to receive(:save).ordered
expect(db).to receive(:commit).ordered
# If called out of this order, the test fails
# Different return values per call
allow(random).to receive(:number)
.and_return(1, 2, 3, 4)
# First call → 1, second → 2, third → 3, fourth → 4
# After that it always returns → 4 (the last value)
# Raise an exception
allow(api).to receive(:fetch).and_raise(Net::TimeoutError, "Connection timed out")
allow(parser).to receive(:parse).and_raise(JSON::ParserError)
# Run a custom block
allow(cache).to receive(:get) do |key|
"cached_#{key}_value" # value based on the argument
end
# Call the original implementation after intercepting
allow(object).to receive(:method).and_call_original
# Do nothing (void method)
allow(logger).to receive(:log) # implicitly returns nil
Partial Mocks — Stubbing Methods on Real Objects #
Partial mocks allow you to stub one or more methods on a real object, while other methods keep their original implementation:
class Order
def total
items.sum(&:price) - discount
end
def discount
premium_member? ? subtotal_before_discount * 0.2 : 0
end
def process!
send_notification
deduct_stock
create_invoice
mark_completed
end
end
RSpec.describe Order do
let(:order) { create(:order) }
describe "#process!" do
it "sends a notification after processing" do
# Stub methods that have external side effects
allow(order).to receive(:send_notification)
allow(order).to receive(:deduct_stock)
allow(order).to receive(:create_invoice)
order.process!
# Verify the notification was sent
expect(order).to have_received(:send_notification).once
end
end
describe "#total" do
it "applies a discount for premium members" do
# Stub only the methods that determine the discount,
# let the total calculation stay real
allow(order).to receive(:premium_member?).and_return(true)
allow(order).to receive(:subtotal_before_discount).and_return(100_000)
expect(order.discount).to eq(20_000)
end
end
end
verify_partial_doubles = trueinspec_helper.rbis highly recommended — it ensures the stubbed method actually exists on the real object. Without it, you can stub a nonexistent method and the test still passes, while the production code crashes withNoMethodError.
Spies — Observation Without Blocking #
A spy is an approach where you let the code run normally, then verify its interactions after the fact:
RSpec.describe NotificationService do
# spy creates a double that accepts any method (no NoMethodError)
let(:logger) { spy("Logger") }
it "logs every notification sent" do
service = NotificationService.new(logger: logger)
service.send_to("[email protected]", "Message A")
service.send_to("[email protected]", "Message B")
# Verify after all execution is done
expect(logger).to have_received(:info).twice
expect(logger).to have_received(:info).with(/Message A/)
expect(logger).to have_received(:info).with(/Message B/)
end
end
# spy vs a regular double:
# double — must allow/expect all methods before they're called
# spy — accepts any method, suitable for objects you observe but don't control
# instance_spy — a spy with interface verification
let(:mailer) { instance_spy(EmailService) }
Fakes — Alternative Implementations #
A fake is a more honest implementation than a mock — it has real logic but is simplified for testing purposes:
# ANTI-PATTERN: tests depending on a real database — slow!
RSpec.describe ProductService do
it "calculates the total price" do
Product.create!(name: "A", price: 10_000)
Product.create!(name: "B", price: 20_000)
expect(ProductService.total_price).to eq(30_000)
end
end
# CORRECT: a fake repository — fast, no database
class FakeProductRepository
def initialize(products = [])
@products = products
end
def all
@products
end
def add(product)
@products << product
product
end
def find(id)
@products.find { |p| p[:id] == id }
end
def delete(id)
@products.reject! { |p| p[:id] == id }
end
end
RSpec.describe ProductService do
let(:repo) do
FakeProductRepository.new([
{ id: 1, name: "Laptop", price: 15_000_000 },
{ id: 2, name: "Mouse", price: 350_000 },
{ id: 3, name: "Keyboard", price: 450_000 }
])
end
subject(:service) { ProductService.new(repo) }
it "calculates the total price of all products" do
expect(service.total_price).to eq(15_800_000)
end
it "finds a product by ID" do
expect(service.find(2)[:name]).to eq("Mouse")
end
end
WebMock — Mocking HTTP Requests #
WebMock blocks all real HTTP requests during tests and replaces them with predefined responses:
gem install webmock
# spec/spec_helper.rb
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
# allow_localhost: true so Capybara can still open a local browser
require 'webmock/rspec'
require 'net/http'
RSpec.describe GithubClient do
describe "#user_profile" do
context "when the API returns user data" do
before do
stub_request(:get, "https://api.github.com/users/namikazebadri")
.with(
headers: {
"Accept" => "application/vnd.github.v3+json",
"Authorization" => /^Bearer /
}
)
.to_return(
status: 200,
body: JSON.generate({
login: "namikazebadri",
name: "Namikazebadri",
public_repos: 42
}),
headers: { "Content-Type" => "application/json" }
)
end
it "returns the parsed profile data" do
client = GithubClient.new(token: "test-token")
profile = client.user_profile("namikazebadri")
expect(profile[:login]).to eq("namikazebadri")
expect(profile[:public_repos]).to eq(42)
end
end
context "when the API returns 404" do
before do
stub_request(:get, /api.github.com\/users\//)
.to_return(status: 404, body: '{"message":"Not Found"}')
end
it "raises UserNotFoundError" do
client = GithubClient.new(token: "test-token")
expect { client.user_profile("nonexistent") }
.to raise_error(GithubClient::UserNotFoundError)
end
end
context "when the network times out" do
before do
stub_request(:get, /api.github.com/)
.to_timeout
end
it "raises TimeoutError" do
client = GithubClient.new(token: "test-token")
expect { client.user_profile("anyone") }
.to raise_error(Net::OpenTimeout)
end
end
end
end
# Verify requests were actually made
RSpec.describe BillingService do
it "sends a notification to the payment gateway" do
stub = stub_request(:post, "https://api.midtrans.com/charge")
.to_return(status: 200, body: '{"transaction_id":"txn-123"}')
BillingService.new.process(order_id: 1, amount: 150_000)
expect(stub).to have_been_requested.once
# or more specifically:
expect(a_request(:post, "https://api.midtrans.com/charge")
.with(body: hash_including(order_id: "1")))
.to have_been_made.once
end
end
VCR — Recording and Replaying HTTP Requests #
VCR records real HTTP interactions the first time they run, then replays them on subsequent runs. It’s great for testing API clients without always needing an internet connection:
gem install vcr
# spec/spec_helper.rb
require 'vcr'
VCR.configure do |config|
config.cassette_library_dir = "spec/cassettes" # folder storing recordings
config.hook_into :webmock # use WebMock as the backend
config.configure_rspec_metadata! # enable the :vcr tag in RSpec
# Hide sensitive data from recordings
config.filter_sensitive_data("<API_KEY>") { ENV["API_KEY"] }
config.filter_sensitive_data("<AUTH_TOKEN>") { ENV["AUTH_TOKEN"] }
config.default_cassette_options = {
record: :new_episodes # only record new requests, replay existing ones
}
end
# Use with the :vcr tag
RSpec.describe WeatherClient, :vcr do
it "fetches the weather for Jakarta" do
client = WeatherClient.new
data = client.weather("Jakarta")
# First time: actually calls the API, records to a cassette
# Subsequent times: loads from the cassette, no real request
expect(data[:temperature]).to be_a(Numeric)
expect(data[:city]).to eq("Jakarta")
end
end
# Or with an explicit block
RSpec.describe WeatherClient do
it "fetches the weather for Bandung" do
VCR.use_cassette("weather/bandung") do
client = WeatherClient.new
data = client.weather("Bandung")
expect(data[:city]).to eq("Bandung")
end
end
end
Timecop — Controlling Time in Tests #
Timecop allows time-dependent tests to run deterministically:
gem install timecop
require 'timecop'
RSpec.describe PromoHandler do
describe "#promo_active?" do
it "returns true during the promo period" do
Timecop.freeze(Time.new(2024, 8, 17, 12, 0, 0)) do
promo = PromoHandler.new(
start: Time.new(2024, 8, 1),
end: Time.new(2024, 8, 31)
)
expect(promo.active?).to be true
end
end
it "returns false after the promo ends" do
Timecop.freeze(Time.new(2024, 9, 1)) do
promo = PromoHandler.new(
start: Time.new(2024, 8, 1),
end: Time.new(2024, 8, 31)
)
expect(promo.active?).to be false
end
end
end
describe "#duration_elapsed" do
it "calculates how many hours the promo has been running" do
Timecop.travel(Time.new(2024, 8, 17, 10, 0, 0)) do
promo = PromoHandler.new(start: Time.new(2024, 8, 17, 8, 0, 0))
expect(promo.duration_elapsed).to eq(2 * 3600) # 2 hours in seconds
end
end
end
end
# Timecop.freeze — time stops completely (Time.now doesn't move)
# Timecop.travel — time moves normally from a specific point
# Timecop.scale — speeds up time N times
# Always make sure Timecop is reset after tests:
after { Timecop.return }
# Or use the auto-returning block:
Timecop.freeze(Time.now) { ... }
Mocking Anti-Patterns to Avoid #
# ANTI-PATTERN 1: Over-mocking — mocking every dependency
# This test passes but doesn't test anything real
RSpec.describe UserService do
it "creates a user" do
allow(User).to receive(:new).and_return(double(valid?: true, save: true))
allow(Mailer).to receive(:send_welcome)
allow(EventBus).to receive(:publish)
allow(Cache).to receive(:invalidate)
# This test is almost entirely mocks — what's actually being tested?
result = UserService.create(email: "[email protected]")
expect(result).to be true
end
end
# CORRECT: test real behavior, mock only external side effects
RSpec.describe UserService do
it "creates a user and saves it to the database" do
# Only mock external I/O
allow(Mailer).to receive(:send_welcome)
expect {
UserService.create(email: "[email protected]", name: "Rina")
}.to change(User, :count).by(1)
expect(User.last.email).to eq("[email protected]")
end
end
# ANTI-PATTERN 2: Mocks testing implementation, not behavior
it "uses the AES-256 algorithm for encryption" do
expect(OpenSSL::Cipher).to receive(:new).with("AES-256-CBC")
EncryptionService.encrypt("sensitive data")
end
# If the algorithm changes to equally secure AES-128, the test breaks
# even though the behavior (encrypted data) hasn't changed
# CORRECT: test the result (ciphertext can be decrypted)
it "encrypts and decrypts data correctly" do
original_data = "sensitive data"
encrypted = EncryptionService.encrypt(original_data)
decrypted = EncryptionService.decrypt(encrypted)
expect(decrypted).to eq(original_data)
end
# ANTI-PATTERN 3: Mocking without verify_partial_doubles
# The mocked method doesn't exist on the real class
allow(user).to receive(:email_verified?) # this method doesn't exist!
# The test passes, but in production: NoMethodError!
# CORRECT: enable verify_partial_doubles in spec_helper
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true # errors if the method doesn't exist
end
# ANTI-PATTERN 4: allow_any_instance_of — avoid whenever possible
allow_any_instance_of(Mailer).to receive(:send)
# Hard to track which instance is stubbed
# Makes the code hard to refactor
# CORRECT: inject the dependency so it can be mocked precisely
class NotificationService
def initialize(mailer: Mailer.new) # dependency injection
@mailer = mailer
end
end
let(:mailer) { instance_double(Mailer) }
let(:service) { NotificationService.new(mailer: mailer) }
When to Mock, When Not To #
Use mocks/stubs for:
✓ External services — third-party APIs, payment gateways
✓ Email and notifications — you don't want to really send emails in tests
✓ Clock/time — make tests deterministic with Timecop
✓ Random — control random results for reproducible tests
✓ Logging — no need to verify log output in every test
✓ Jobs/queues — verify the job is enqueued, not executed
Avoid mocks for:
✗ Models and core business calculations — test directly, don't stub
✗ Simple value objects — create real instances
✗ Just because setup is tedious — fix the code design, don't use mocks
✗ Avoiding slow tests caused by complex queries — consider
database test fixtures or factory_bot + database_cleaner
Summary #
- Stub (
allow) to control input, Mock (expect) to verify interactions — don’t useexpectif you don’t care whether the method is called; useallowinstead.instance_doubleis safer thandouble— it verifies the method actually exists on the real class with a matching signature, preventing tests from passing while production code crashes.spyandhave_receivedfor post-fact observation — more natural thanexpect(...).to receive, which must be written before the code executes.verify_partial_doubles = trueis mandatory — without it, partial mocks on nonexistent methods go undetected.- WebMock for HTTP testing — block all real requests in the test environment; define responses explicitly, including errors and timeouts.
- VCR for complex API clients — record real interactions once, replay them forever; hide API keys from cassettes with
filter_sensitive_data.- Timecop for time-dependent tests — use
Timecop.freezefor stopped time andTimecop.travelfor time moving from a specific point.- Avoid
allow_any_instance_of— hard to track and makes refactoring dangerous; use dependency injection so you can mock a specific instance.- Don’t over-mock — if a test is full of stubs with no real assertions, you’re not testing anything; a good test only mocks external side effects.
- Fakes beat mocks for complex dependencies — an in-memory repository is more reliable and easier to understand than a long chain of
allows.