RSpec Shared Examples, Shared Contexts, and Custom Matchers: A Deep Dive
RSpec shared examples and shared contexts eliminate massive test duplication when multiple models or controllers share the same behavior. This post covers advanced patterns including custom matchers, metadata tagging, and composable shared contexts that scale across large Rails codebases.
Test suites grow fast. A Rails app with a dozen models, each requiring the same soft-delete behavior, pagination, or authorization checks, can accumulate hundreds of near-identical test blocks within months. RSpec's shared examples, shared contexts, and custom matchers exist precisely to prevent this—but most tutorials only scratch the surface.
This post covers the patterns that make large test suites maintainable: parameterized shared examples, composable shared contexts, custom matchers with failure messages that actually help, and metadata tagging to run focused subsets.
Shared Examples: Beyond the Basics
The simplest shared example looks like this:
RSpec.shared_examples "a soft-deletable model" do
it "sets deleted_at on destroy" do
subject.destroy
expect(subject.deleted_at).not_to be_nil
end
it "excludes soft-deleted records from default scope" do
subject.destroy
expect(described_class.all).not_to include(subject)
end
it "can be restored" do
subject.destroy
subject.restore
expect(subject.deleted_at).to be_nil
expect(described_class.all).to include(subject)
end
end
RSpec.describe Post, type: :model do
subject { create(:post) }
it_behaves_like "a soft-deletable model"
end
RSpec.describe Comment, type: :model do
subject { create(:comment) }
it_behaves_like "a soft-deletable model"
endThat works. But shared examples become genuinely powerful when they accept parameters and blocks.
Parameterized Shared Examples
When the shared behavior depends on which attribute or association is involved, pass parameters:
RSpec.shared_examples "validates presence of" do |attribute|
it "requires #{attribute}" do
subject.send(:"#{attribute}=", nil)
expect(subject).not_to be_valid
expect(subject.errors[attribute]).to include("can't be blank")
end
end
RSpec.shared_examples "has a slugged attribute" do |attribute, slug_attribute|
it "generates #{slug_attribute} from #{attribute} before validation" do
subject.send(:"#{attribute}=", "Hello World")
subject.valid?
expect(subject.send(slug_attribute)).to eq("hello-world")
end
it "does not overwrite a manually set #{slug_attribute}" do
subject.send(:"#{slug_attribute}=", "custom-slug")
subject.valid?
expect(subject.send(slug_attribute)).to eq("custom-slug")
end
end
RSpec.describe Article, type: :model do
subject { build(:article) }
it_behaves_like "validates presence of", :title
it_behaves_like "validates presence of", :body
it_behaves_like "has a slugged attribute", :title, :slug
endPassing a Block to Shared Examples
For cases where the setup differs between consumers, accept a block:
RSpec.shared_examples "a paginatable resource" do |per_page: 25|
before do
create_list(described_class.name.underscore.to_sym, per_page + 5)
end
it "returns #{per_page} records by default" do
results = described_class.paginate(page: 1)
expect(results.size).to eq(per_page)
end
it "returns the correct page" do
results = described_class.paginate(page: 2)
expect(results.size).to eq(5)
end
end
RSpec.describe Product, type: :model do
it_behaves_like "a paginatable resource", per_page: 20
end
RSpec.describe Order, type: :model do
it_behaves_like "a paginatable resource" # uses default 25
endWhen the block form is needed for more complex customization, use include_examples with a block (available in RSpec 3.x):
RSpec.shared_examples "requires authentication" do
it "redirects unauthenticated users" do
perform_request
expect(response).to redirect_to(login_path)
end
end
RSpec.describe ArticlesController, type: :controller do
describe "GET #index" do
it_behaves_like "requires authentication" do
let(:perform_request) { get :index }
end
end
endShared Contexts
Shared contexts set up state and helpers without defining examples. Use shared_context to DRY up before hooks, let definitions, and helper methods.
RSpec.shared_context "authenticated user" do
let(:user) { create(:user) }
before do
sign_in user
end
end
RSpec.shared_context "admin user" do
include_context "authenticated user"
before do
user.update!(role: :admin)
end
end
RSpec.shared_context "with stubbed payment gateway" do
before do
allow(PaymentGateway).to receive(:charge).and_return(
OpenStruct.new(success?: true, transaction_id: "txn_abc123")
)
end
endInclude them explicitly or use metadata (covered below):
RSpec.describe DashboardController, type: :controller do
include_context "authenticated user"
describe "GET #index" do
it "renders successfully" do
get :index
expect(response).to have_http_status(:ok)
end
end
endComposable Shared Contexts
Shared contexts that include other shared contexts create composable building blocks:
RSpec.shared_context "published article setup" do
include_context "authenticated user"
let(:article) { create(:article, :published, author: user) }
let(:draft) { create(:article, :draft, author: user) }
end
RSpec.describe ArticlePolicy, type: :policy do
include_context "published article setup"
it "allows the author to edit their published article" do
expect(policy).to permit(user, article)
end
endCustom Matchers
Built-in matchers cover most cases, but custom matchers make domain assertions read like English—and produce failure messages that point directly to the problem.
Basic Custom Matcher
# spec/support/matchers/be_a_valid_email.rb
RSpec::Matchers.define :be_a_valid_email do
match do |actual|
actual.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
end
failure_message do |actual|
"expected #{actual.inspect} to be a valid email address"
end
failure_message_when_negated do |actual|
"expected #{actual.inspect} not to be a valid email address"
end
end
# Usage
expect(user.email).to be_a_valid_email
expect("not-an-email").not_to be_a_valid_emailMatchers with Parameters and Chaining
RSpec::Matchers.define :have_enqueued_mailer do |mailer_class|
chain :with_method do |method|
@method = method
end
chain :for do |recipient|
@recipient = recipient
end
match do |_actual|
jobs = ActiveJob::Base.queue_adapter.enqueued_jobs
jobs.any? do |job|
args = job[:args]
args[0] == mailer_class.name &&
(@method.nil? || args[1] == @method.to_s) &&
(@recipient.nil? || args.include?(@recipient))
end
end
failure_message do
parts = ["expected a #{mailer_class} mailer to be enqueued"]
parts << "with method #{@method}" if @method
parts << "for #{@recipient}" if @recipient
parts.join(" ")
end
end
# Usage
expect {
UserMailer.welcome(user).deliver_later
}.to have_enqueued_mailer(UserMailer)
.with_method(:welcome)
.for(user.email)Compound Matchers for JSON Responses
RSpec::Matchers.define :be_a_json_response do |expected_status|
match do |response|
@actual_status = response.status
@actual_content = response.content_type
@body_parseable = parse_body(response)
response.status == expected_status &&
response.content_type.include?("application/json") &&
@body_parseable
end
def parse_body(response)
JSON.parse(response.body)
true
rescue JSON::ParserError
false
end
failure_message do |response|
messages = []
messages << "expected status #{expected_status}, got #{@actual_status}" if @actual_status != expected_status
messages << "expected JSON content type, got #{@actual_content}" unless @actual_content.include?("application/json")
messages << "response body is not valid JSON" unless @body_parseable
messages.join("\n")
end
end
# Usage
expect(response).to be_a_json_response(200)
expect(response).to be_a_json_response(422)Metadata Tagging
RSpec metadata lets you attach arbitrary key-value pairs to examples and example groups. Combined with hooks and configuration, metadata enables powerful selective execution.
Defining Metadata
RSpec.describe User, :model, :with_cache do
# ...
end
RSpec.describe "payment flow", :integration, :slow do
# ...
end
RSpec.describe "admin endpoints", :requires_admin do
# ...
endHooking Into Metadata
# spec/support/metadata_hooks.rb
RSpec.configure do |config|
# Automatically include shared context based on metadata
config.include_context "authenticated user", :authenticated
config.include_context "admin user", :requires_admin
config.include_context "with stubbed payment gateway", :with_stubbed_payments
# Set up caching for tagged specs
config.before(:each, :with_cache) do
allow(Rails.cache).to receive(:fetch).and_call_original
end
# Tag slow specs and skip them in fast mode
config.before(:each, :slow) do
skip "Skipping slow spec (set RUN_SLOW=1 to include)" unless ENV["RUN_SLOW"]
end
# Force database cleaner strategy for integration specs
config.around(:each, :integration) do |example|
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.cleaning { example.run }
DatabaseCleaner.strategy = :transaction
end
endRunning Specific Tags
# Run only unit model specs
bundle exec rspec --tag model
# Run integration specs
bundle exec rspec --tag integration
# Exclude slow specs
bundle exec rspec --tag ~slow
# Run specs requiring admin context
bundle exec rspec --tag requires_admin
# Combine tags
bundle exec rspec --tag model --tag ~slowAutomatic Shared Context via Metadata
One of the most useful patterns: include shared contexts automatically based on spec type or custom metadata, eliminating explicit include_context calls:
RSpec.shared_context "API request helpers", :api do
let(:json_body) { JSON.parse(response.body) }
let(:json_data) { json_body["data"] }
let(:json_errors) { json_body["errors"] }
def auth_headers(user)
token = JsonWebToken.encode(user_id: user.id)
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
end
end
RSpec.configure do |config|
config.include_context "API request helpers", :api
end
# Now any spec tagged :api gets these helpers automatically
RSpec.describe "POST /api/v1/articles", :api, type: :request do
let(:user) { create(:user) }
it "creates an article" do
post "/api/v1/articles",
params: { article: { title: "Test" } }.to_json,
headers: auth_headers(user)
expect(response).to have_http_status(:created)
expect(json_data["title"]).to eq("Test")
end
endOrganizing Support Files
As your shared examples and matchers grow, a clear directory structure prevents chaos:
spec/
support/
shared_examples/
soft_deletable.rb
paginatable.rb
searchable.rb
auditable.rb
shared_contexts/
authenticated.rb
admin.rb
api_helpers.rb
payment_stubs.rb
matchers/
be_a_valid_email.rb
have_enqueued_mailer.rb
be_a_json_response.rb
helpers/
api_helpers.rb
file_upload_helpers.rbLoad them all from spec/rails_helper.rb:
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }Or load selectively to keep boot time down:
RSpec.configure do |config|
config.before(:suite) do
Dir[Rails.root.join("spec/support/matchers/**/*.rb")].each { |f| require f }
end
endPutting It All Together
A real-world example combining all patterns—shared context via metadata, shared examples with parameters, and a custom matcher:
# spec/requests/api/v1/resources_spec.rb
RSpec.describe "Articles API", :api, :requires_admin, type: :request do
# :api includes API request helpers (json_body, auth_headers)
# :requires_admin includes admin user context (admin_user let)
it_behaves_like "a paginatable resource" do
let(:perform_request) { get "/api/v1/articles", headers: auth_headers(admin_user) }
end
describe "POST /api/v1/articles" do
it "returns a valid JSON response with created status" do
post "/api/v1/articles",
params: { article: attributes_for(:article) }.to_json,
headers: auth_headers(admin_user)
expect(response).to be_a_json_response(201)
expect(json_data["slug"]).to be_present
end
end
endKey Takeaways
- Use
shared_examplesfor behavior contracts: soft-delete, pagination, authorization checks - Use
shared_contextfor reusable setup: authenticated sessions, stubs, helper methods - Pass parameters to shared examples for attribute-specific validations
- Write custom matchers with descriptive failure messages—they pay off during debugging
- Use metadata tags to automatically include contexts and control which specs run in CI vs. local development
- Keep support files organized by type; load them selectively if boot time becomes a concern
Shared examples and contexts are not just about reducing lines of code. They enforce that every model implementing an interface actually passes the same behavioral contract—catching regressions that copy-pasted tests would miss.