RSpec verifying doubles as an architectural boundary for unit tests
Verifying doubles replace plain doubles at owned seams so unit tests fail when a collaborator's API changes. This note covers requirements, trust boundaries, failure modes, and when to use integration tests instead.
31 Jul 2026, 11:59 UTC

The problem with plain doubles in unit tests
Plain double() gives you isolation from a collaborator, but no guarantee the collaborator actually has the API you are stubbing. A test can pass against an imaginary method name, argument list, or class method, and the suite stays green while production breaks after a refactor.
The useful takeaway is to use verifying doubles at owned seams where a class under test calls a collaborator. The double is still a test double, but RSpec checks it against the real class at definition time.
Requirements for isolated unit tests that do not drift
Unit tests need isolation so a failure points to the class under test, not to downstream services. Isolation must be bounded by a trust boundary: the test trusts that the collaborator's public API is stable, and the test must fail if that API changes.
Plain doubles violate the second requirement. Verifying doubles restore it by requiring the real constant to be loaded and verifying that stubbed methods exist on that constant. For methods defined in Ruby, RSpec also checks argument arity, meaning a change in required parameters is caught.
Smallest suitable design with verifying doubles
Use instance_double(ClassName) when the class under test receives an instance of a collaborator, class_double(ClassName) for class methods, and object_double(real_instance) when the collaborator is built dynamically.
Example at a seam you own:
# spec/services/order_placer_spec.rb
RSpec.describe OrderPlacer do
let(:payment_gateway) { instance_double(PaymentGateway) }
let(:order) { instance_double(Order, total: 100) }
subject(:placer) { described_class.new(payment_gateway: payment_gateway) }
before do
allow(payment_gateway).to receive(:charge).with(order, 100).and_return(true)
end
it "charges the gateway" do
placer.call(order)
expect(payment_gateway).to have_received(:charge).with(order, 100)
end
endIf PaymentGateway#charge is renamed or its arity changes, RSpec raises a verification error when the example is defined, not later at runtime. The double is created in the spec file, run from the project root with read access to the codebase. The risk is that the constant must be loadable; an unloaded constant causes a verification error unless the suite is configured to relax verification for unloaded constants.
Configuration is explicit:
# spec/spec_helper.rb
RSpec.configure do |config|
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end
endWith verify_partial_doubles true, stubbing a method that does not exist on the real object raises immediately. This flag is version-sensitive; confirm the default in your installed RSpec version before relying on it.
Trust and data boundaries
Verifying doubles create a trust boundary between the class under test and the collaborator class. The test trusts the real class definition as the source of truth for method names and signatures. Data does not cross the boundary; only messages are verified.
class_double verifies class methods and blocks stubbing of private methods by default. object_double wraps a real instance and verifies against it, useful when the collaborator is constructed by a factory and you do not want to reference the class name directly.
Arity checking applies to methods defined in Ruby. Methods implemented in C extensions may not be fully verified. Methods provided via method_missing, dynamic delegation, or runtime-defined APIs are invisible to verification and can produce false confidence.
Operational checks
Keep verifying doubles at seams you own and keep the unit suite fast. Run a smaller integration suite that exercises the real collaboration to confirm the verified double matches runtime behavior.
Practical checks:
- Write a spec with
instance_double, then rename a stubbed method in the real class. The unit test should fail with a verification error at double creation. - Attempt to verify a double for a class that is not loaded. The suite should raise an error, prompting an explicit decision to load the class or relax verification.
- Run the integration path that uses the real objects together after refactoring to ensure the contract still holds.
Treat a verified-double failure during refactoring as a signal to update callers, not to weaken the double.
Failure modes
Intended failure: refactoring a collaborator breaks dependent unit tests. This is the design goal.
Unintended failures:
- Dynamic APIs. A collaborator that uses
method_missingwill cause verification errors for methods that exist at runtime but not statically. - Unloaded constants. If the constant cannot be resolved, verification is skipped unless configured otherwise, silently weakening the guarantee.
- Over-doubling. Doubling value objects or trivial collaborators creates brittle tests that mirror implementation rather than behavior.
Limitations to document in the codebase: verifying doubles do not protect boundaries that cross processes, such as HTTP, queues, or databases.
When to change the design
Change the design when the boundary is not owned or not in-process.
For boundaries crossing processes, verifying doubles give no protection. Use contract tests or integration tests that exercise the real protocol.
For value objects and trivial collaborators, using the real object is simpler and more honest than any double. The cost of isolation is not justified.
If a collaborator is highly dynamic, prefer a real instance or an integration test over a verifying double to avoid false verification errors.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.