Testing Ruby Service Objects with RSpec — My Go-To Approach

by Eric Hanson, Backend Developer at Clean Systems Consulting

Why service objects test cleanly — when you let them

A service object with explicit dependencies, keyword arguments, and a single call method is almost perfectly shaped for unit testing. No HTTP context, no controller stack, no view rendering. You pass in what it needs, call it, and assert on what comes back.

The tests get ugly when you fight this structure — when dependencies are hardcoded inside the class, when the service reaches into global state, or when assertions focus on implementation rather than outcomes. The approach below keeps tests fast, isolated, and meaningful.

The service under test

Using the PlaceOrder service from the service objects article as the subject:

class PlaceOrder
  def self.call(...)
    new(...).call
  end

  def initialize(user:, cart:, payment_method:)
    @user           = user
    @cart           = cart
    @payment_method = payment_method
  end

  def call
    return Result.fail("Cart is empty") if cart.items.empty?

    charge = PaymentGateway.charge(amount: cart.total, method: payment_method)
    return Result.fail("Payment declined: #{charge.decline_reason}") unless charge.ok?

    order = Order.create!(user: user, items: cart.items, charge: charge)
    OrderMailer.confirmation(order).deliver_later

    Result.ok(order)
  rescue InventoryService::ReservationError => e
    Result.fail("Item unavailable: #{e.message}")
  end

  private

  attr_reader :user, :cart, :payment_method
end

Structure: describe the class, context the paths

The outer describe block names the class. Each distinct execution path gets a context block with a condition stated in plain English. The naming convention I use: context "when [condition]" for branching, context "with [input shape]" for input variation:

RSpec.describe PlaceOrder do
  subject(:result) { described_class.call(user: user, cart: cart, payment_method: payment_method) }

  let(:user)           { build_stubbed(:user) }
  let(:cart)           { instance_double(Cart, items: [item], total: 50_00) }
  let(:item)           { instance_double(CartItem, product_id: 1, quantity: 2) }
  let(:payment_method) { "pm_test_visa" }

  context "when the cart is empty" do
    let(:cart) { instance_double(Cart, items: [], total: 0) }

    it "returns a failed result" do
      expect(result).to be_failed
    end

    it "includes the reason" do
      expect(result.error).to eq("Cart is empty")
    end
  end

  context "when payment is declined" do
    let(:charge) { instance_double(PaymentGateway::Charge, ok?: false, decline_reason: "insufficient funds") }

    before do
      allow(PaymentGateway).to receive(:charge).and_return(charge)
    end

    it "returns a failed result" do
      expect(result).to be_failed
    end

    it "includes the decline reason" do
      expect(result.error).to include("insufficient funds")
    end
  end

  context "when payment succeeds" do
    let(:charge) { instance_double(PaymentGateway::Charge, ok?: true) }
    let(:order)  { instance_double(Order, id: 42) }

    before do
      allow(PaymentGateway).to receive(:charge).and_return(charge)
      allow(Order).to receive(:create!).and_return(order)
      allow(OrderMailer).to receive_message_chain(:confirmation, :deliver_later)
    end

    it "returns a successful result" do
      expect(result).to be_ok
    end

    it "returns the created order as the value" do
      expect(result.value).to eq(order)
    end
  end
end

Two things to note. First, described_class instead of the class name directly — if you rename the class, the test still references the right thing. Second, each it block asserts one thing. Two-assertion examples obscure which assertion failed and make test names meaningless.

build_stubbed vs instance_double vs create

Three tools for constructing test objects, each with a different cost and purpose:

build_stubbed (FactoryBot) creates an in-memory object that behaves like a persisted ActiveRecord instance — it has an id, responds to persistence predicates like persisted?, but never touches the database. Use it for the primary object under test when the service reads attributes off it but doesn't save it.

instance_double creates a verified double — a test object constrained to the actual interface of the class it doubles. If Cart doesn't have a total method, instance_double(Cart, total: 50_00) raises at test time, not at runtime. This is the single most important testing tool for service objects. It catches interface mismatches immediately rather than in production.

create (FactoryBot + database) is for integration tests where you need real database queries. In unit tests for service objects, create is almost always the wrong choice — it hits the database, slows the suite, and adds transactional complexity. If your service test requires a create, the service has a hidden database dependency that should be injected or mocked.

The decision hierarchy: build_stubbed for AR model arguments, instance_double for collaborator objects, create only when testing the database interaction directly.

Mocking collaborators — what to stub and what not to

PaymentGateway.charge is stubbed in the tests above because it's an external dependency with side effects. The rule: stub at the boundary of the class under test. If the service owns it, test it directly. If the service delegates to it, stub it.

What counts as a boundary:

  • External services and API clients
  • Mailers (stub deliver_later, never actually send in unit tests)
  • Other service objects called as sub-steps
  • Background job enqueuing

What doesn't count as a boundary:

  • ActiveRecord models in tests that use the real database (integration tests only)
  • Pure computation methods on injected objects

For mailers specifically, have_enqueued_mail is cleaner than stubbing the mailer chain:

it "enqueues a confirmation email" do
  expect { result }.to have_enqueued_mail(OrderMailer, :confirmation)
end

This uses ActiveJob's test adapter rather than mocking the mailer directly — it verifies that the mail job was enqueued, not that deliver_later was called on a specific object.

Testing the rescue path

Infrastructure exceptions should be tested explicitly, not assumed to propagate:

context "when inventory reservation fails" do
  before do
    allow(PaymentGateway).to receive(:charge).and_return(
      instance_double(PaymentGateway::Charge, ok?: true)
    )
    allow(InventoryService).to receive(:reserve)
      .and_raise(InventoryService::ReservationError, "item out of stock")
  end

  it "returns a failed result" do
    expect(result).to be_failed
  end

  it "surfaces the reservation error message" do
    expect(result.error).to include("out of stock")
  end
end

This verifies that the rescue clause catches the right exception type and maps it to a result correctly. Without this test, a typo in the exception class name (InventoryService::ResevationError) passes silently until the exception propagates in production.

Shared examples for result protocol

If your codebase has multiple service objects returning the same Result type, shared examples enforce the protocol consistently:

RSpec.shared_examples "a successful result" do
  it { is_expected.to be_ok }
  it { is_expected.not_to be_failed }
  it "has a non-nil value" do
    expect(subject.value).not_to be_nil
  end
end

RSpec.shared_examples "a failed result" do |expected_error_pattern|
  it { is_expected.to be_failed }
  it { is_expected.not_to be_ok }
  if expected_error_pattern
    it "has a meaningful error message" do
      expect(subject.error).to match(expected_error_pattern)
    end
  end
end

# Usage
context "when payment succeeds" do
  it_behaves_like "a successful result"
end

context "when cart is empty" do
  it_behaves_like "a failed result", /empty/
end

Shared examples catch result objects that return the wrong structure — ok? missing, value not set on success. They're most valuable when a new developer adds a service object and forgets a return path.

The integration test boundary

Unit tests cover paths and result values. Integration tests cover database state. These belong in separate files or describe blocks:

# spec/services/place_order_spec.rb — unit tests, no database
# spec/integration/place_order_integration_spec.rb — database, real objects

RSpec.describe "PlaceOrder integration", type: :integration do
  it "persists the order to the database" do
    user = create(:user)
    cart = create(:cart, :with_items)

    result = PlaceOrder.call(
      user:           user,
      cart:           cart,
      payment_method: "pm_test_visa"
    )

    expect(Order.find(result.value.id)).to be_persisted
    expect(Order.find(result.value.id).user).to eq(user)
  end
end

The integration test uses create because it's explicitly testing the database side effect. It stubs the payment gateway even here — you don't want real charges in integration tests, and the gateway's behavior is covered by its own test suite or a dedicated VCR cassette.

The test that earns its existence

Every test in a service object spec should be answering one of three questions: does this path return the right result type, does it carry the right data, or does it produce the right side effects? Tests that assert on internal state — instance variables set mid-call, private method invocation counts, argument shapes of internal calls — are testing implementation and will break on every refactor.

The signal that a test is worth keeping: if you deleted it and introduced the bug it covers, would you catch the bug before shipping? If the answer is yes — because another test at a higher level catches it — the test is redundant. If the answer is no, it earns its place.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Los Angeles Startups Are Turning to Async Remote Backend Contractors to Cut Through the Noise

LA's backend hiring market is loud, expensive, and slow. A growing number of startups are finding a quieter way through it.

Read more

Why 9 Developers Cannot Deliver a Project 9 Months Faster

It sounds logical: if one developer takes 9 months, then 9 developers should take 1 month. But software projects don’t work like that.

Read more

Spring Boot API Rate Limiting — rack-attack Equivalent in Java

Rate limiting protects APIs from abuse, enforces fair usage, and prevents accidental runaway clients from taking down infrastructure. Here is how to implement per-user, per-IP, and per-endpoint rate limiting in Spring Boot with Bucket4j and Redis.

Read more

What Java 21 Changes for Production Java Developers — Virtual Threads, Records, Sealed Classes, and Pattern Matching

Java 21 is an LTS release with several features that change how production code is written — not incrementally, but fundamentally. Here is what each feature actually does, where it applies, and what it replaces.

Read more