Tests & Security Medium severity

Direct Third-Party Mocking

RSpec tests that stub methods directly on third-party gem classes (Stripe::Charge.create, Aws::S3::Client.new), coupling the test suite to the library’s internal API and producing green tests that fail in production the moment the gem changes.

Before / After

Problematic Pattern
# spec/services/checkout_spec.rb
it 'charges the customer' do
allow(Stripe::Charge).to receive(:create)
  .and_return(double(id: 'ch_123'))

Checkout.call(order)

expect(Stripe::Charge).to have_received(:create)
end

# Stripe gem updates to 10.0: Stripe::Charge renamed.
# Tests still pass. Production raises NoMethodError.
Target Architecture
# app/services/payment_adapter.rb
class PaymentAdapter
def self.charge(amount:, source:)
  response = Stripe::Charge.create(
    amount: amount,
    source: source
  )
  Result.new(id: response.id, status: response.status)
end

Result = Struct.new(:id, :status, keyword_init: true)
end

# spec/services/checkout_spec.rb
it 'charges the customer' do
allow(PaymentAdapter).to receive(:charge)
  .and_return(
    PaymentAdapter::Result.new(id: 'ch_123', status: 'succeeded')
  )

Checkout.call(order)

expect(PaymentAdapter).to have_received(:charge)
end

# For integration-level confidence, record real
# HTTP with VCR:
# VCR.use_cassette('stripe_charge') do
#   PaymentAdapter.charge(amount: 1000, source: 'tok_visa')
# end

Why this hurts

Third-party gem APIs are outside the application’s control. The gem’s method signatures, return types, exception hierarchies, and internal class structure change across versions, sometimes in minor releases and certainly in major ones. When tests stub directly on the gem’s public classes, the stubs assert only that the application called the library in a specific way, not that the library behaves the way the application expects. A minor gem update that renames a method from create to create! or changes a returned object from an OpenStruct to a formal class passes every test that stubs the old shape.

The failure mode is particularly nasty because it looks like a successful deploy. CI runs green, the feature branch merges, production deploys, and the first real payment request raises NoMethodError on a method the test suite thought was stubbed. Rollback requires reverting the gem bump separately from the application changes, and the original PR looked harmless because all tests passed. Over time, the team learns to fear gem upgrades and avoids them, which compounds into the “Hardcoded Gem Dependencies” anti-pattern.

Stubbing at the third-party class layer also makes tests unreadable. A spec that reads allow(Aws::S3::Client).to receive(:new).and_return(double(put_object: ...)) communicates nothing about the application’s business logic; it communicates the shape of a gem the reader may not know. When the gem changes and the stubs need updating, editing the spec requires reading the gem’s source to understand what the new shape should be.

An Adapter class defines a stable internal interface the application controls. The adapter wraps the third-party call and exposes a domain-oriented method (PaymentAdapter.charge) that the application’s business logic consumes. Tests stub the adapter, which is cheap and safe to change. Integration tests use VCR to record real HTTP traffic against the third-party sandbox and replay it deterministically, catching API drift when the recorded responses no longer match what the adapter expects.

Get Expert Help

Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.