Stopping Test Suite Bloat with RSpec Shared Examples
Stop duplicating tests for similar classes. Learn how to use RSpec shared examples to define behavioral contracts and keep your test suite DRY and maintainable.
01 Nov 2025, 04:08 UTC

The Problem: Copy-Paste Test Suites
When you build a Ruby application with multiple classes implementing the same interface—such as different payment gateways, various file exporters, or several models sharing a specific behavior—you often find yourself writing the same tests over and over. If you have three classes that must all respond to .calculate_total, you end up with three identical sets of assertions.
This duplication creates a maintenance burden. If the business logic for .calculate_total changes, you must update three different spec files. Missing one leads to inconsistent test coverage and silent regressions. The solution is to decouple the behavioral requirement from the specific class using RSpec shared examples.
Defining Behavioral Contracts
Shared examples allow you to define a set of tests once and apply them to any object that should exhibit that behavior. Think of it as a "contract" that a class must fulfill to be considered valid.
You define these using shared_examples. Inside this block, you write your assertions as you normally would, but instead of referencing a specific class, you reference a generic object (often passed in as a parameter or provided by the calling context).
Implementing Shared Examples with Parameters
While you can rely on instance variables, passing parameters to shared examples makes the tests more explicit and easier to debug. This avoids the "mystery guest" problem, where it is unclear where a variable was defined when a test fails.
The following example demonstrates how to test two different storage classes—LocalStorage and S3Storage—that must both implement a save method.
# spec/support/shared_examples/storage_interface_spec.rb
RSpec.shared_examples "a storage provider" do |storage_instance|
it "successfully saves a string of data" do
result = storage_instance.save("test-data")
expect(result).to be true
end
it "returns false when saving empty data" do
result = storage_instance.save("")
expect(result).to be false
end
end
# spec/models/local_storage_spec.rb
RSpec.describe LocalStorage do
let(:storage) { LocalStorage.new("/tmp/data") }
# We pass the specific instance into the shared example
it_behaves_like "a storage provider", -> { storage }
end
# spec/models/s3_storage_spec.rb
RSpec.describe S3Storage do
let(:storage) { S3Storage.new(bucket: "my-app-bucket") }
it_behaves_like "a storage provider", -> { storage }
end
Execution and Verification
To verify that the shared examples are executing correctly, run your specs with the documentation formatter:
# Run from the project root
bundle exec rspec --format documentation
Expected Result: You should see the shared example descriptions expanded under both LocalStorage and S3Storage in the output, confirming that the tests ran for both implementations.
Trade-offs: The Cost of Abstraction
Shared examples are powerful, but they introduce a layer of indirection. There are two primary risks to monitor:
- Obscured Failures: When a test fails inside a shared example, the stack trace points to the shared block, not the specific class spec. If you have 20 classes using the same shared example, identifying the specific edge case for one class can be slower.
- Over-Generalization: There is a temptation to force a class into a shared example even if it has slight behavioral differences. This leads to complex
if/elselogic inside theshared_examplesblock, which defeats the purpose of a clean contract.
If you find yourself adding too many conditional checks inside a shared example, it is a sign that the classes no longer share the same behavior and should have separate specs.
Actionable Summary
To integrate shared examples into your workflow:
- Identify three or more classes that share the same method signatures and expected outcomes.
- Move those assertions into a
shared_examplesblock in aspec/supportfile. - Use
it_behaves_likeand pass the object as a parameter to maintain clarity. - Verify the expansion of tests using
--format documentation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.