RSpec Shared Examples and Contexts: DRY Test Patterns
Test duplication is a real problem. When you have ten model specs that each test the same #timestamps behavior, or five controller specs that each verify authentication, copy-paste is the wrong answer. RSpec gives you two tools for this: shared examples and shared contexts.
Shared examples let you define a group of tests once and run them against multiple subjects. Shared contexts let you define setup (let blocks, before hooks, helper methods) once and include it wherever you need it.
This post covers both in depth, with practical patterns for structuring large test suites.
Shared Examples
Basic Definition and Use
Define shared examples with shared_examples_for (or the alias shared_examples):
shared_examples_for "a timestamped model" do
it "has a created_at timestamp" do
expect(subject.created_at).not_to be_nil
end
it "has an updated_at timestamp" do
expect(subject.updated_at).not_to be_nil
end
it "sets created_at on save" do
expect { subject.save }.to change { subject.created_at }.from(nil)
end
endInclude the examples in a spec with it_behaves_like:
describe User do
subject { User.new(name: "Alice") }
it_behaves_like "a timestamped model"
end
describe Post do
subject { Post.new(title: "Hello") }
it_behaves_like "a timestamped model"
end
describe Comment do
subject { Comment.new(body: "Great post") }
it_behaves_like "a timestamped model"
endThe output groups shared examples under their host describe block with the shared example name:
User
behaves like a timestamped model
has a created_at timestamp
has an updated_at timestamp
sets created_at on saveinclude_examples vs it_behaves_like
it_behaves_like creates a nested example group. include_examples inlines the examples directly into the current group:
describe User do
it_behaves_like "a timestamped model" # creates nested group
include_examples "a timestamped model" # inlines — no nesting
endUse it_behaves_like by default — the nesting makes failures easier to trace. Use include_examples only when you specifically need the examples at the same nesting level (rare).
Passing Parameters to Shared Examples
Shared examples can receive arguments:
shared_examples_for "a paginatable collection" do |default_per_page|
it "returns the default page size" do
expect(subject.per_page).to eq(default_per_page)
end
it "starts at page 1" do
expect(subject.current_page).to eq(1)
end
end
describe ProductCollection do
subject { ProductCollection.new }
it_behaves_like "a paginatable collection", 20
end
describe OrderCollection do
subject { OrderCollection.new }
it_behaves_like "a paginatable collection", 50
endShared Examples with let
Shared examples can define their own let blocks, and they can also rely on let blocks defined in the including spec:
shared_examples_for "a soft-deletable record" do
it "is not deleted by default" do
expect(subject.deleted_at).to be_nil
end
it "can be soft-deleted" do
subject.soft_delete
expect(subject.deleted_at).not_to be_nil
end
it "is excluded from the default scope after deletion" do
subject.save!
subject.soft_delete
expect(described_class.all).not_to include(subject)
end
end
describe Article do
subject { Article.new(title: "Hello") }
it_behaves_like "a soft-deletable record"
endThe shared example uses described_class, which refers to the class passed to the outermost describe in the including spec. This is a useful trick for making shared examples work across multiple classes.
Passing a Block to it_behaves_like
You can customize shared examples on inclusion by passing a block:
shared_examples_for "an API endpoint" do
it "returns 200" do
expect(response.status).to eq(200)
end
it "returns JSON" do
expect(response.content_type).to include("application/json")
end
end
describe "GET /users" do
before { get "/users", headers: auth_headers }
it_behaves_like "an API endpoint" do
let(:expected_count) { 5 }
it "returns the correct number of users" do
expect(json_body["users"].length).to eq(expected_count)
end
end
endVariables defined in the block are available inside the shared examples as well.
Shared Contexts
Shared contexts define setup — let, before, after, subject, and helper methods — that you want to reuse across multiple specs. They don't define examples themselves.
Basic Definition
shared_context "authenticated user" do
let(:user) { create(:user, :confirmed) }
let(:auth_headers) { { "Authorization" => "Bearer #{user.auth_token}" } }
before do
sign_in(user)
end
endInclude it with include_context:
describe "GET /dashboard" do
include_context "authenticated user"
it "returns the user's data" do
get "/dashboard", headers: auth_headers
expect(response.status).to eq(200)
end
endShared Contexts with Parameters
Like shared examples, contexts can take parameters:
shared_context "with a user of role" do |role|
let(:user) { create(:user, role: role) }
let(:auth_headers) { { "Authorization" => "Bearer #{user.auth_token}" } }
end
describe "admin-only endpoints" do
include_context "with a user of role", :admin
# ...
end
describe "manager endpoints" do
include_context "with a user of role", :manager
# ...
endAutomatic Inclusion with Metadata
RSpec lets you auto-include shared contexts based on metadata tags. Define the auto-inclusion in spec_helper.rb or rails_helper.rb:
RSpec.configure do |config|
config.include_context "authenticated user", :authenticated
config.include_context "with database cleaning", :db
config.include_context "with stubbed external APIs", :api
endThen tag specs that need it:
describe "GET /orders", :authenticated, :api do
it "returns orders for the user" do
get "/orders", headers: auth_headers
expect(response.status).to eq(200)
end
endNo explicit include_context call needed. The metadata triggers automatic inclusion. This is the cleanest approach for cross-cutting setup like authentication, database transactions, and HTTP stubbing.
Where to Put Shared Examples and Contexts
Two common approaches:
Option 1: Dedicated support files
spec/
support/
shared_examples/
timestamped_model.rb
soft_deletable.rb
api_endpoint.rb
shared_contexts/
authenticated_user.rb
database_cleaner.rbLoad them in spec_helper.rb:
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }Option 2: Inline in the spec file
For small projects or one-off shared examples that are only used in nearby specs, define them at the top of the spec file. Keep the definition close to where it's used.
The dedicated support file approach scales better. Use it for anything shared across more than two or three files.
Real-World Pattern: Testing Multiple Implementations
A common scenario: you have multiple classes that implement the same interface (strategy pattern, adapters, serializers). Test the interface contract once with shared examples:
# spec/support/shared_examples/storage_adapter.rb
shared_examples_for "a storage adapter" do
describe "#store" do
it "saves the data" do
adapter.store("key", "value")
expect(adapter.fetch("key")).to eq("value")
end
it "overwrites existing data" do
adapter.store("key", "old")
adapter.store("key", "new")
expect(adapter.fetch("key")).to eq("new")
end
it "returns the stored value" do
expect(adapter.store("key", "value")).to eq("value")
end
end
describe "#fetch" do
context "when the key exists" do
before { adapter.store("key", "value") }
it "returns the value" do
expect(adapter.fetch("key")).to eq("value")
end
end
context "when the key does not exist" do
it "returns nil" do
expect(adapter.fetch("missing")).to be_nil
end
end
end
describe "#delete" do
before { adapter.store("key", "value") }
it "removes the key" do
adapter.delete("key")
expect(adapter.fetch("key")).to be_nil
end
end
end# spec/adapters/redis_adapter_spec.rb
describe RedisAdapter do
let(:adapter) { RedisAdapter.new(redis: Redis.new) }
it_behaves_like "a storage adapter"
end
# spec/adapters/memory_adapter_spec.rb
describe MemoryAdapter do
let(:adapter) { MemoryAdapter.new }
it_behaves_like "a storage adapter"
end
# spec/adapters/s3_adapter_spec.rb
describe S3Adapter do
let(:adapter) { S3Adapter.new(bucket: "test-bucket") }
it_behaves_like "a storage adapter"
endAdding a new adapter? Write the class, run the shared examples against it, and you've verified the full interface contract. This pattern is powerful for plugin systems, adapter layers, and strategy objects.
Real-World Pattern: Policy Testing
Authorization logic often follows the same structure: role A can do X, role B can't. Shared examples keep this DRY:
shared_examples_for "an admin-only action" do
context "when the user is an admin" do
let(:user) { create(:user, :admin) }
it "allows the action" do
expect { perform_action }.not_to raise_error
end
end
context "when the user is not an admin" do
let(:user) { create(:user) }
it "raises NotAuthorizedError" do
expect { perform_action }.to raise_error(Policy::NotAuthorizedError)
end
end
end
describe UserDeletionPolicy do
let(:perform_action) { policy.delete!(target_user) }
let(:policy) { UserDeletionPolicy.new(user) }
let(:target_user) { create(:user) }
it_behaves_like "an admin-only action"
end
describe BillingPolicy do
let(:perform_action) { policy.view_invoices! }
let(:policy) { BillingPolicy.new(user) }
it_behaves_like "an admin-only action"
endCommon Mistakes
Overusing shared examples. Shared examples are for genuine contract duplication — the same behavior tested in multiple places. Don't extract examples just because two specs happen to look similar. Accidental similarity doesn't warrant sharing. If the underlying concepts diverge, inlined tests are easier to understand.
Hiding context in shared examples. If a shared example has invisible dependencies (relies on let blocks that must exist in the including spec), document them explicitly:
# Requires: let(:user), let(:policy)
shared_examples_for "a policy check" do
# ...
endDeep nesting. it_behaves_like inside it_behaves_like is hard to follow. Keep it to one level.
Shared examples for setup. If you only need shared setup (no examples), use shared_context, not shared_examples_for. The names exist for a reason.
Not loading support files. Shared examples defined in files under spec/support/ won't load automatically unless you require them in spec_helper.rb. This is the most common reason shared examples silently don't run — RSpec won't error on an unknown shared example name unless you try to include it.
Summary
shared_examples_for+it_behaves_like: define and run the same examples against different subjectsshared_context+include_context: share setup (let, before hooks) across specs- Metadata-based auto-inclusion:
config.include_context "name", :tagfor cross-cutting concerns - Put shared definitions in
spec/support/and require them inspec_helper.rb - Use shared examples for interface contracts (adapters, policies, serializers)
- Avoid sharing just because two specs look similar — they need to be semantically the same behavior