RSpec Shared Examples, Shared Contexts, and Custom Matchers: A Deep Dive

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"
end

That 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
end

Passing 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
end

When 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
end

Shared 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
end

Include 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
end

Composable 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
end

Custom 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_email

Matchers 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
  # ...
end

Hooking 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
end

Running 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 ~slow

Automatic 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
end

Organizing 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.rb

Load 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
end

Putting 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
end

Key Takeaways

  • Use shared_examples for behavior contracts: soft-delete, pagination, authorization checks
  • Use shared_context for 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.

Read more

Start now free