RSpec Shared Examples and Contexts: DRY Test Patterns

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
end

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

The 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 save

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

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

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

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

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

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

Shared 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

  # ...
end

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

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

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

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

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

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

Deep 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 subjects
  • shared_context + include_context: share setup (let, before hooks) across specs
  • Metadata-based auto-inclusion: config.include_context "name", :tag for cross-cutting concerns
  • Put shared definitions in spec/support/ and require them in spec_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

Read more

Start now free