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 --> H

RSpec 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 = true in spec_helper.rb is 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 with NoMethodError.

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 use expect if you don’t care whether the method is called; use allow instead.
  • instance_double is safer than double — it verifies the method actually exists on the real class with a matching signature, preventing tests from passing while production code crashes.
  • spy and have_received for post-fact observation — more natural than expect(...).to receive, which must be written before the code executes.
  • verify_partial_doubles = true is 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.freeze for stopped time and Timecop.travel for 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.

← Previous: Unit Testing   Next: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact