RSpec Shared Examples: A Task Guide for DRYing Interface Contracts
Learn how to define RSpec shared examples once and reuse them across classes that implement the same interface, with parameterization, file organization, and debugging tips.
13 Jan 2026, 06:00 UTC

The Problem: Duplicate Specs for the Same Contract
When multiple Ruby classes implement the same interface—serializers, payment adapters, API clients—their test files often become near-identical copies. A change to the shared contract means editing every spec file, and the copies slowly drift apart.
The useful takeaway: extract the common behavior into an RSpec shared_examples block, include it in each implementing class with it_behaves_like or include_examples, and pass the object under test as an explicit argument. This keeps a single source of truth for the contract while letting each class supply its own subject.
Prerequisites and Assumptions
This guide assumes a Ruby project with RSpec 3.x or newer, either through the rspec gem or rspec-rails. You should already be comfortable with describe, context, and it blocks.
An example group is any describe or context block that contains specs. Shared examples are named example groups that can be injected into other example groups.
Organizing Shared Example Files
Place reusable shared examples in spec/shared_examples/ (or spec/support/shared_examples/). In a Rails application, files under spec/support are often auto-loaded by the generated rails_helper.rb. In a plain Ruby project, you must require them explicitly in spec_helper.rb:
# spec/spec_helper.rb
Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].each { |f| require f }
Run this from your project root. You need read access to the spec/ directory. If the files are not loaded, RSpec will raise a NameError or simply run zero examples when you reference the shared group.
Defining and Including a Shared Example Group
Define a shared example group with shared_examples and a descriptive string name:
# spec/shared_examples/a_json_serializer.rb
RSpec.shared_examples "a JSON serializer" do |record|
it "returns a Hash" do
expect(described_class.new(record).as_json).to be_a(Hash)
end
it "includes the record id" do
result = described_class.new(record).as_json
expect(result[:id]).to eq(record.id)
end
end
Include it in a spec file with it_behaves_like, which creates a nested example group in the output:
# spec/serializers/user_serializer_spec.rb
require 'rails_helper'
RSpec.describe UserSerializer do
it_behaves_like "a JSON serializer", User.new(id: 1)
end
Alternatively, use include_examples to run the specs inline without adding a nested context to the documentation output:
RSpec.describe PostSerializer do
include_examples "a JSON serializer", Post.new(id: 2)
end
Choosing Between it_behaves_like and include_examples
| Method | Output structure | Best for |
|---|---|---|
it_behaves_like | Adds a nested group named after the shared examples | Clearer failure messages when many classes share the same contract |
include_examples | Inlines specs into the current group | Keeping documentation output flat when the shared name would be redundant |
Parameterizing Shared Examples
Shared examples accept arguments after the name. The block receives them as parameters. This is the safest way to avoid implicit dependencies—references to instance variables such as @user that are defined in the parent context but invisible inside the shared block.
In the example above, record is passed explicitly. The shared specs do not know whether they are testing a User or a Post; they only know the object responds to id and is accepted by described_class.
Expected Checks
After adding the shared examples and includes, run the relevant specs from your project root:
bundle exec rspec spec/serializers
You should see the shared specs execute once for each class that includes them. For two serializer classes, the two shared examples should produce four total specs. If the count is lower, the shared file is probably not being loaded. If you see a NameError for the shared example name, verify the require statement or Rails support auto-loading path.
To check a single class, run:
bundle exec rspec spec/serializers/user_serializer_spec.rb
Recovery Options and Common Pitfalls
The Mystery Guest
A mystery guest occurs when a shared example depends on setup defined far away in a parent let or before block. The test passes or fails for reasons hidden from the include site. Recover by passing every required object as an explicit argument to the shared block.
Deeply Nested Shared Examples
Including shared examples inside other shared examples multiplies cognitive load and makes failure tracebacks harder to read. If you find yourself nesting two or more levels deep, flatten the hierarchy by extracting the innermost behavior into a plain Ruby helper method or a separate shared group used directly by the concrete spec.
Reverting a Change
Because this change only edits test files, reverting is straightforward: remove the it_behaves_like or include_examples lines from the affected spec files and delete the shared example file if it is no longer used. No application runtime state is altered.
Limitations: When Shared Examples Hurt More Than They Help
Shared examples are effective for genuine interface contracts, but they are a poor fit for incidental similarity. If two classes happen to share a few assertions today but are likely to diverge tomorrow, inlining the specs keeps each test honest and local.
Overuse also obscures the test suite. A reader opening user_serializer_spec.rb cannot see what is actually being asserted without opening the shared file. Use shared examples when the contract is stable and the duplication is real; otherwise, prefer plain specs.
Practical Verification
To confirm the shared examples are working as intended, intentionally break one expectation inside the shared block—for example, change eq(record.id) to eq(999). Run the suite again. You should see failures in every class that includes the shared group, proving the shared specs are executing for each implementer. Restore the assertion afterward.
This approach gives you a single, maintainable place to enforce interface contracts across your Ruby application without copying and pasting specs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.