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.