Reducing RSpec Duplication with Shared Examples: A Practical Guide
Define a contract once with RSpec shared examples and reuse it across example groups — including parameterization, it_behaves_like vs. include_examples, and the traps to avoid.
05 Jan 2026, 18:51 UTC

The outcome you want
When two or more classes implement the same contract — a mixin, an interface-like module, or interchangeable service objects — you end up writing the same specs repeatedly. Shared examples let you define that behavior once and reuse it across example groups, so a contract change means editing one file, not five.
This guide assumes RSpec 3.x with a standard spec_helper.rb/rails_helper.rb setup. Everything here is stable, long-established RSpec behavior.
Prerequisites
- An existing RSpec suite where you can run
bundle exec rspecfrom the project root. - At least two classes (or contexts) that genuinely share behavior. If only one class has the behavior today, plain specs are simpler — don't extract prematurely.
- Write access to the
spec/directory. No special permissions or gems beyondrspec(orrspec-rails) are needed.
Step 1: Define the shared behavior
Create a file such as spec/support/shared_examples/publishable.rb. If you use rspec-rails, files under spec/support are typically auto-loaded via the Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } line in rails_helper.rb; in a plain Ruby project, require the file explicitly in spec_helper.rb.
# spec/support/shared_examples/publishable.rb
RSpec.shared_examples 'a publishable resource' do
it 'is not published by default' do
expect(subject.published?).to be(false)
end
it 'becomes published after #publish!' do
subject.publish!
expect(subject.published?).to be(true)
end
it 'records a publication timestamp' do
subject.publish!
expect(subject.published_at).not_to be_nil
end
endThe key convention: the shared block calls subject, and each calling example group is responsible for defining that subject. RSpec resolves subject at runtime from the calling context, not from the shared block.
Step 2: Include it from multiple example groups
# spec/models/article_spec.rb
RSpec.describe Article do
subject { described_class.new(title: 'Draft') }
it_behaves_like 'a publishable resource'
end
# spec/models/video_spec.rb
RSpec.describe Video do
subject { described_class.new(file: 'intro.mp4') }
it_behaves_like 'a publishable resource'
endRun bundle exec rspec spec/models/article_spec.rb spec/models/video_spec.rb from the project root. You should see the three shared examples reported separately under each describe block — six examples total. If you instead see a NilClass error inside the shared examples, the calling group is missing a subject definition (or an implicit subject from described_class.new doesn't fit your constructor).
Step 3: Parameterize when behavior varies
Shared examples accept arguments, which handles the common case where the contract is the same but details differ — for example, different roles with different permission levels:
RSpec.shared_examples 'an access-controlled endpoint' do |expected_status|
it 'responds with the expected status' do
subject
expect(response).to have_http_status(expected_status)
end
end
RSpec.describe 'Admin dashboard' do
context 'as an admin' do
before { sign_in create(:user, :admin) }
subject { get '/admin' }
it_behaves_like 'an access-controlled endpoint', :ok
end
context 'as a guest' do
subject { get '/admin' }
it_behaves_like 'an access-controlled endpoint', :forbidden
end
endVerify parameterization by intentionally passing the wrong status once (e.g., :ok for the guest context) and confirming the example fails with the expected status mismatch in the failure message. Then revert.
it_behaves_like vs. include_examples
These two are not interchangeable, and picking the wrong one causes subtle bugs:
| Method | Scoping | When to use |
|---|---|---|
it_behaves_like | Creates a nested example group; let and before inside the shared block are isolated | Default choice. Safe when the shared block defines its own helpers or hooks. |
include_examples | Injects examples directly into the current group | Only when the shared examples must see the caller's exact scope — and you're sure there are no let name collisions. |
The risk with include_examples: if the shared block defines let(:user) and the calling group also defines let(:user), one silently overrides the other depending on load order. With it_behaves_like, the nested scope prevents this.
Limitations and how to avoid the common traps
- Mystery guests. If the shared example depends on setup that lives in the calling file (
let(:record), abeforehook), a reader of the shared file can't tell what's required. Mitigate by documenting requiredletdefinitions in a comment at the top of the shared block, and prefersubjectas the single expected interface. - Debugging depth. Failures inside shared examples show a stack trace pointing into the shared file, and deeply nested shared examples (a shared block that itself calls
it_behaves_like) make this worse. Keep nesting to one level. - Over-extraction. If two specs merely look similar but test different contracts, forcing them into a shared example couples unrelated classes. Extract only genuine shared contracts.
Verifying the result
Run the full suite with documentation format to see the structure:
bundle exec rspec --format documentationCheck that each calling context lists the shared examples under its own heading (confirming it_behaves_like created nested groups), and that the total example count equals what you expect. A quick sanity check: temporarily break one implementation (e.g., make Video#publish! a no-op) and confirm only the video examples fail — this proves the shared examples are actually exercising each subject rather than a hardcoded object. Revert the break afterward. No other rollback is needed; shared examples only add spec files and change nothing in application code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.