RSpec Shared Examples and Shared Contexts: Reusable Test Behaviour

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
end

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

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

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

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

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

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

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

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

One 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.

Read more

Start now free