RSpec Shared Examples and Shared Contexts: Reusable Test Behaviour
When multiple objects share the same interface or behaviour, copy-pasting test cases is a maintenance trap. RSpec's shared examples and shared contexts let you write that behaviour once and include it wherever it applies.
Shared Examples
Defining shared examples
# spec/support/shared_examples/paginatable.rb
RSpec.shared_examples "a paginatable resource" do
describe ".page" do
it "returns the first page by default" do
expect(described_class.page(1).count).to be <= 25
end
it "accepts a per-page option" do
expect(described_class.page(1, per: 10).count).to be <= 10
end
end
endIncluding shared examples
RSpec.describe Post, type: :model do
it_behaves_like "a paginatable resource"
end
RSpec.describe Comment, type: :model do
it_behaves_like "a paginatable resource"
endBoth Post and Comment now run the full pagination suite. Fix the shared example once to fix all consumers.
Passing Parameters to Shared Examples
Shared examples accept a block for customisation:
RSpec.shared_examples "a soft-deletable record" do |factory_name|
let(:record) { create(factory_name) }
it "sets deleted_at on destroy" do
record.destroy
expect(record.reload.deleted_at).not_to be_nil
end
it "excludes soft-deleted records from default scope" do
record.destroy
expect(described_class.all).not_to include(record)
end
end
RSpec.describe Article, type: :model do
it_behaves_like "a soft-deletable record", :article
end
RSpec.describe Invoice, type: :model do
it_behaves_like "a soft-deletable record", :invoice
endYou can also pass a block to it_behaves_like and reference it via subject or a let defined inside:
RSpec.shared_examples "serializable" do
it "serialises without error" do
expect { subject.to_json }.not_to raise_error
end
end
RSpec.describe User, type: :model do
subject { build(:user) }
it_behaves_like "serializable"
endinclude_examples vs it_behaves_like
Both include shared examples, but they differ in scoping:
it_behaves_like |
include_examples |
|
|---|---|---|
| Scope | Nested describe block |
Current example group |
let isolation |
Yes — inner lets don't leak | No — lets merge with outer scope |
| Recommended | Yes, by default | Only when you intentionally want shared scope |
Prefer it_behaves_like — the scoping is predictable and prevents accidental let collisions.
Shared Contexts
A shared context provides setup (hooks, lets, helper methods) without defining examples. Use it when multiple example groups need the same scaffolding.
Defining a shared context
# spec/support/shared_contexts/authenticated_user.rb
RSpec.shared_context "authenticated user" do
let(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
let(:auth_headers) { { "Authorization" => "Bearer #{token}" } }
before { user } # ensure user exists before each example
endIncluding a shared context
RSpec.describe "POST /orders", type: :request do
include_context "authenticated user"
it "creates an order" do
post "/orders", params: order_params, headers: auth_headers
expect(response).to have_http_status(:created)
end
endAuto-including with metadata
RSpec.configure do |config|
config.include_context "authenticated user", authenticated: true
end
# Any example group tagged with authenticated: true gets the context automatically:
RSpec.describe "GET /profile", type: :request, authenticated: true do
it "returns the user's profile" do
get "/profile", headers: auth_headers
expect(response).to have_http_status(:ok)
end
endOrganising Shared Files
Keep shared examples and contexts discoverable:
spec/
support/
shared_examples/
paginatable.rb
soft_deletable.rb
serializable.rb
shared_contexts/
authenticated_user.rb
database_with_seed_data.rbRequire them in spec/spec_helper.rb or spec/rails_helper.rb:
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }Real-World Example: API Contract Tests
RSpec.shared_examples "a JSON API endpoint" do |method, path|
it "returns JSON content type" do
send(method, path, headers: { "Accept" => "application/json" })
expect(response.content_type).to include("application/json")
end
it "includes a request ID header" do
send(method, path)
expect(response.headers["X-Request-Id"]).to be_present
end
end
RSpec.describe "GET /users", type: :request do
it_behaves_like "a JSON API endpoint", :get, "/users"
end
RSpec.describe "GET /orders", type: :request do
it_behaves_like "a JSON API endpoint", :get, "/orders"
endOne definition enforces the contract across every endpoint.
When Not to Use Shared Examples
Shared examples are powerful but can obscure intent. Avoid them when:
- The shared behaviour is used in only one place — just write it inline
- The abstraction requires complex parameter passing that makes the test harder to read than copy-paste
- Failures in shared examples are hard to trace back to the original context
Keeping Tests Visible
Shared examples run the same code paths as inline tests, but they hide those paths from static analysis and line-coverage tools. Pair them with a continuous monitoring layer — HelpMeTest runs your full RSpec suite on a schedule and alerts you when shared example failures emerge in CI. Try it free at helpmetest.com.