RSpec and Test Doubles

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18) and RSpec 3.13 (rspec-core 3.13.6, rspec-expectations 3.13.5, rspec-mocks 3.13.8, as installed for the examples); features newer than Ruby 3.0 name the version that introduced them. RSpec is not part of Ruby: it is a community framework (https://rspec.info/) that you add as a gem. It is a behavior-description framework: a test file is written as a specification of how an object behaves, in nested groups whose names read as sentences, with expectations written as expect(actual).to matcher and with a built-in library for test doubles. It is very widely used in Ruby applications, and Minitest (see Testing with Minitest) is the one that ships with Ruby.

RSpec is really three gems bundled by the rspec gem: rspec-core (example groups, hooks, the rspec command, configuration, tags), rspec-expectations (expect and the matchers) and rspec-mocks (doubles, stubs and message expectations). Each can be used alone and each has its own configuration block, which is why spec_helper.rb below has a expect_with and a mock_with section. The page follows the same shelf scenario as the rest of the section (Isbn, Author, Book, Loan, Store::Memory and Catalog), adds a small collaborator Mailer and a Lender that uses it so that there is something worth doubling, and shows how to organise a suite, write expectations, replace collaborators, share behavior between specs and select what runs. It ends with test-driven development and a comparison with Minitest.

Related pages: Creating and Publishing Gems, and Rake shows the gem layout the specs live in, RubyGems and Bundler explains why the suite runs with bundle exec, Style, Linting and Static Analysis covers RuboCop and its RSpec extension, Idioms, Style and Clean Code covers refactoring under a test net, SOLID and Design Patterns covers the dependency injection that makes doubles easy, and Dynamic Dispatch and Method Definition explains the send and method_missing machinery that doubles rely on. For the same topic in another language see Go testing.

Every example on this page was run on Ruby 4.0.7 with RSpec 3.13 (the spec files with rspec -I lib -I ../shelf/lib from a scratch project whose lib holds the Mailer and Lender classes and whose sibling shelf directory holds the gem, see the shelf gem source), syntax-checked with ruby -wc, run with warnings enabled and linted with RuboCop 1.92 using the configuration of the running shelf gem. The output is real. One RuboCop setting is added for specs, shown in the layout section: a describe block is one long block by nature, so Metrics/BlockLength is switched off under spec/. The spec files use the Mailer and Lender classes shown in the doubles section; the e-mail address is a placeholder.

Installing and laying out a suite

RSpec is a development dependency. Add it to the Gemfile (a gem’s shelf.gemspec would use add_development_dependency) and run the suite through Bundler so that the locked versions are used:

# frozen_string_literal: true

source "https://rubygems.org"

gemspec

group :development, :test do
  gem "rspec", "~> 3.13"
  gem "rubocop", "~> 1.92", require: false
end
bundle install
bundle exec rspec --init

rspec --init creates two files and nothing else (real output: create .rspec and create spec/spec_helper.rb). The generated spec_helper.rb is a long file of commented-out recommended settings; the .rspec file holds command-line options, one per line, that are added to every run:

--require spec_helper
--format documentation
--color

By convention specs live under spec/, mirror the lib/ tree and end in _spec.rb; the rspec command adds spec to the load path and loads every *_spec.rb it finds under it. Shared helpers go in spec/support/.

shelf/
├── lib/shelf/...
├── spec/
│   ├── spec_helper.rb
│   ├── support/
│   │   ├── book_helpers.rb
│   │   └── store_examples.rb
│   └── shelf/
│       ├── isbn_spec.rb
│       ├── catalog_spec.rb
│       └── store_spec.rb
├── .rspec
└── .rubocop.yml

A spec_helper.rb that is deliberately small but sets the options a modern suite should have:

# frozen_string_literal: true

require "shelf"

Dir[File.join(__dir__, "support", "*_helpers.rb")].each { |f| require f }

RSpec.configure do |config|
  config.disable_monkey_patching!
  config.order = :random
  Kernel.srand config.seed

  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
    mocks.verify_doubled_constant_names = true
  end

  config.filter_run_excluding :slow unless ENV["SLOW"]
  config.example_status_persistence_file_path = ".rspec_status"
  config.shared_context_metadata_behavior = :apply_to_host_groups
end

The settings do the following:

Setting Effect

disable_monkey_patching!

Removes the global describe (and shared_examples, shared_context) from Object and the should syntax, so every group starts with an explicit RSpec.describe. Zero-monkey-patching mode keeps the test framework out of every object’s method table.

order = :random with srand config.seed

Runs examples in a random order and prints the seed (Randomized with seed 1234), so hidden order dependencies surface; --seed 1234 replays the same order.

expect_with options

Configure rspec-expectations; include_chain_clauses_in_custom_matcher_descriptions puts chained clauses such as .with(...) into generated descriptions.

verify_partial_doubles

Makes a stub on a real object (a partial double) fail unless the object really has that method with a compatible signature.

verify_doubled_constant_names

Makes instance_double("Some::Name") with a constant name that does not exist fail instead of silently passing.

filter_run_excluding :slow

Skips examples tagged :slow unless the environment variable is set; see the tags section.

example_status_persistence_file_path

Records the last result of each example in a file so that --only-failures and --next-failure work. Add .rspec_status to .gitignore.

shared_context_metadata_behavior

:apply_to_host_groups is documented in rspec-core 3.13 as the only behavior in RSpec 4; set it now so that metadata of a shared context is applied to the group that includes it.

RuboCop’s default Metrics/BlockLength limit of 25 lines is a poor fit for a describe block, so the project configuration excludes the spec tree:

inherit_from: ../shelf/.rubocop.yml

Metrics/BlockLength:
  Exclude:
    - "spec/**/*"

The RuboCop extension for RSpec specs (rubocop-rspec, community) adds many cops for spec style; it was not installed for this page and is described in the style and linting page. The spec/support/book_helpers.rb helper builds a Book for the examples and is included into every group through RSpec.configure:

# frozen_string_literal: true

module BookHelpers
  def build_book(title = "Ruby Basics", isbn: "978-0-306-40615-7")
    Shelf::Book.new(Shelf::Isbn.new(isbn), title, Shelf::Author.new("A. Writer"))
  end
end

RSpec.configure { |config| config.include BookHelpers }

Example groups, examples and naming

RSpec.describe opens an example group. Its argument is the thing being described, usually a class (its description is the class name) or a string. Inside a group, describe and context open nested groups (they are aliases: context is for a condition, describe for a unit of behavior), and it (aliases specify and example) defines one example, the unit that passes or fails. Printing the tree with the documentation formatter turns the nesting into prose, which is why the strings are written so that "Shelf::Isbn .new with a bad check digit raises ArgumentError naming the input" reads as a sentence.

# frozen_string_literal: true

RSpec.describe Shelf::Isbn do
  subject(:isbn) { described_class.new(raw) }

  let(:raw) { "978-0-306-40615-7" }

  describe ".new" do
    context "with a valid ISBN-13" do
      it "strips the hyphens" do
        expect(isbn.digits).to eq("9780306406157")
      end

      it { is_expected.to be_a(Data) }
      it { is_expected.to have_attributes(digits: a_string_matching(/\A\d{13}\z/)) }
    end

    context "with a bad check digit" do
      let(:raw) { "978-0-306-40615-8" }

      it "raises ArgumentError naming the input" do
        expect { isbn }.to raise_error(ArgumentError, /invalid ISBN: 978-0-306-40615-8/)
      end
    end
  end

  describe "#to_s" do
    it "returns the bare digits" do
      expect(isbn.to_s).to eq("9780306406157")
    end
  end

  describe "#==" do
    it "treats the same digits as equal" do
      expect(isbn).to eq(described_class.new("9780306406157"))
    end
  end
end
Output (--order defined)
Shelf::Isbn
  .new
    with a valid ISBN-13
      strips the hyphens
      is expected to be a kind of Data
      is expected to have attributes {digits: (a string matching /\A\d{13}\z/)}
    with a bad check digit
      raises ArgumentError naming the input
  #to_s
    returns the bare digits
  #==
    treats the same digits as equal

The conventions in that file are worth stating as rules:

  • .name for a class method and #name for an instance method. The describe strings ".new" and "#to_s" follow the same notation as Ruby’s own documentation (and as RDoc, see Documentation with RDoc and YARD), so a failure line identifies the method under test. The notation is plain text for humans, not something RSpec parses.

  • described_class is the class passed to the outermost describe. Using it instead of the literal constant keeps the file valid after a rename and works in shared examples.

  • The first line of a group describes the subject, the nesting describes the situation, the it states the result. context "with a bad check digit" followed by it "raises ArgumentError..." reads as a rule.

  • One behavior per example. An example may contain several expect calls when they describe one outcome; aggregate_failures (below) reports all of them.

  • A one-liner it { is_expected.to ... } is generated from the matcher’s description (the second and third lines of the output). It is for trivial properties; anything that needs a reason to exist gets a sentence.

An example with no block is pending (it "later"), pending "reason" or skip "reason" inside an example mark it, and xit / xdescribe skip a whole example or group. Pending examples run and are expected to fail; the run reports a pending example that unexpectedly passes as a failure so the marker is removed. fit, fdescribe and the :focus tag let a developer run only some examples while working; config.filter_run_when_matching :focus is the usual switch, and a focus left in committed code is a common mistake that RuboCop’s RSpec extension flags.

Subject, let and hooks

let(:name) { ... } defines a memoized helper method. The block is evaluated lazily the first time the example calls name, the result is cached for the rest of that example, and the next example starts from scratch. let! evaluates the block eagerly in a before hook. subject is the same mechanism for the object under test: an unnamed subject { ... } is available as subject and through is_expected, a named subject(:isbn) { ... } also defines a helper called isbn. A nested group can override a let of its parent, which is how the "with a bad check digit" context above changes raw without repeating the rest of the setup. Without an explicit subject, RSpec defaults it to described_class.new for a class, and to the described object for anything else.

Hooks run code around examples. before and after default to :example scope (:each is the same); :context (also :all) runs once per group; :suite runs once per run; around receives the example and must call example.run. The order, and the fact that let is lazy and shared inside one example, is easiest to see with a trace:

# frozen_string_literal: true

RSpec.describe "hooks and let" do
  let(:log) { [] }
  let(:lazy) { log << "let evaluated" and :lazy }
  let!(:eager) { log << "let! evaluated" and :eager }

  before(:context) { $stdout.puts "before(:context)" }
  before { log << "before" }
  after { $stdout.puts "after sees #{log.inspect}" }
  around do |example|
    $stdout.puts "around start"
    example.run
    $stdout.puts "around end"
  end

  it "runs hooks in order and memoizes let" do
    lazy
    lazy
    expect(log).to eq(["let! evaluated", "before", "let evaluated"])
    expect(eager).to eq(:eager)
  end

  it "builds fresh state for every example" do
    expect(log).to eq(["let! evaluated", "before"])
  end
end
Output (--order defined --format progress)
before(:context)
around start
after sees ["let! evaluated", "before", "let evaluated"]
around end
.around start
after sees ["let! evaluated", "before"]
around end
.

Finished in 0.00056 seconds (files took 0.04343 seconds to load)
2 examples, 0 failures

The output shows the rules: before(:context) runs once, around wraps before and after, let! runs before the plain before hooks declared after it, a plain let runs on first use, and each example gets a new log. Three cautions apply in practice:

  • State created in before(:context) is shared by all examples of the group and is created outside the per-example isolation, so a change made by one example leaks into the next. Use it for expensive, read-only setup only, and clean up in after(:context). Instance variables set in before(:context) are copied into each example but the objects they point to are shared.

  • Do not hide the point of the example in a distant let. A reader should see in the example what is asserted and in the let only what is incidental. Overriding a let in a context is the idiom for the variable that the context is about.

  • Avoid let! and before for values that only some examples use; lazy let is cheaper and shows dependencies by name.

shared_context packages let, hooks and helpers for reuse, much as shared_examples (below) packages examples; a group pulls it in with include_context.

Matchers

expect(actual).to matcher (and expect(actual).not_to matcher, alias to_not) is the only syntax enabled when monkey patching is disabled. The should syntax of older tutorials needs explicit enabling. Without it, on RSpec 3.13, 1.should eq(1) raises NoMethodError: undefined method 'should' for an instance of Integer in a suite that has called disable_monkey_patching!, and prints a deprecation warning otherwise. The matchers fall in a few families:

Family Matchers

Equality and identity

eq (uses ==), eql, equal and be (object identity), be_within(delta).of(value) for floats

Type and shape

be_a / be_an_instance_of / be_kind_of, have_attributes(...), respond_to(:name), `+satisfy {

x

…​ }+, `match(regexp_or_structure), a_string_matching, a_string_including

Truthiness and predicates

be_truthy, be_falsey, be_nil, and dynamic be_xxx which calls xxx? on the object (be_empty calls empty?; be_on_the_shelf would call on_the_shelf?), have_xxx for has_xxx?

Collections

include, contain_exactly (any order), match_array, start_with, end_with, all(matcher), be_empty, have_key-style predicates

Comparison

be > 3, be <= 3, be_between(1, 5).inclusive

Blocks

raise_error, change { ... }.from(...).to(...) (or .by), output("text").to_stdout, yield_control, yield_with_args, throw_symbol

Compound

matcher.and(matcher) and matcher.or(matcher), plus the aliases a_ / an_ / having_ that make composed matchers read as nouns

Block matchers take a block (expect { ... }) instead of a value, because they must observe what happens while it runs. The sample below covers the ones the shelf gem needs, adds a custom matcher with RSpec::Matchers.define, and shows aggregate_failures:

# frozen_string_literal: true

RSpec::Matchers.define :be_on_the_shelf do
  match { |loan| loan.state == :available }
  failure_message { |loan| "expected #{loan.book.title} to be on the shelf, but it is #{loan.state}" }
end

RSpec.describe Shelf::Loan do
  let(:book) { build_book }

  describe "#describe" do
    {
      available: "Ruby Basics is on the shelf",
      lent: "Ruby Basics is lent",
      overdue: "Ruby Basics is overdue",
      returned: "Ruby Basics came back"
    }.each do |state, sentence|
      it "describes the #{state} state" do
        expect(described_class.new(book, state:).describe).to eq(sentence)
      end
    end
  end

  describe "matchers" do
    it "supports a custom matcher" do
      expect(described_class.new(book)).to be_on_the_shelf
    end

    it "checks several things at once" do
      loan = described_class.new(book, state: :lent)

      aggregate_failures do
        expect(loan.state).to eq(:lent)
        expect(loan.book).to have_attributes(title: "Ruby Basics")
        expect(described_class::STATES).to include(:lent, :overdue).and all(be_a(Symbol))
      end
    end

    it "rejects an unknown state" do
      expect { described_class.new(book, state: :lost) }
        .to raise_error(ArgumentError, "unknown state: lost")
    end
  end
end

RSpec.describe Shelf::Catalog do
  let(:catalog) { described_class.new(store: Shelf::Store::Memory.new) }

  it "grows by one with each add" do
    expect { catalog.add(build_book) }.to change { catalog.count }.from(0).to(1)
  end

  it "yields every book to the block" do
    book = build_book
    catalog.add(book)

    expect { |probe| catalog.each(&probe) }.to yield_with_args(book)
  end
end
Output (--order defined)
Shelf::Loan
  #describe
    describes the available state
    describes the lent state
    describes the overdue state
    describes the returned state
  matchers
    supports a custom matcher
    checks several things at once
    rejects an unknown state

Shelf::Catalog
  grows by one with each add
  yields every book to the block

Two points of the sample deserve a note. First, because a Ruby block can contain a loop, a table of cases becomes a Hash#each that defines one example per row; the description includes the data, so a failure names the row. Second, the failure messages are what the matchers are for. The custom matcher above, and aggregate_failures with two deliberately wrong expectations, report (real output, paths trimmed):

  1) fail custom message
     Failure/Error: expect(Shelf::Loan.new(build_book, state: :lent)).to be_on_the_shelf
       expected Ruby Basics to be on the shelf, but it is lent

  2) fail aggregate
     Got 2 failures from failure aggregation block.

     2.1) Failure/Error: expect(1).to eq(2)

            expected: 2
                 got: 1

            (compared using ==)

     2.2) Failure/Error: expect("a").to eq("b")

            expected: "b"
                 got: "a"

            (compared using ==)

Without aggregate_failures, the first failing expect ends the example and hides the others. Because RSpec 3.13 on Ruby 3.4 and later prints Hash#inspect as {subject: "Hi", to: "a@example.com"} and backtrace lines with quoted names ('block (2 levels) in <top (required)>'), failure output copied from tutorials written for Ruby 3.3 and earlier looks different from yours: {:to=>"a@example.com"} and a backtick before the method name are the old forms.

Test doubles

A test double is an object that stands in for a collaborator during a test, as a stunt double stands in for an actor. The vocabulary is older than RSpec and is not used consistently, so it helps to state it: a dummy is passed to satisfy a signature and never used, a stub returns canned answers, a spy records the calls so they can be checked afterwards, a mock is told in advance which calls to expect and fails if they do not happen, and a fake is a working but simplified implementation. Shelf::Store::Memory is a fake: it is a real in-memory store that stands in for a database-backed one, and it is usually a better choice than a mock because it keeps tests independent of how the code talks to the store. RSpec’s rspec-mocks supplies stubs, spies and mocks through one mechanism: you make an object (a double, or a real object) respond to a message, and you check the messages it received.

The scenario needs a collaborator that is slow and has side effects in production, which is the textbook reason for a double. A Mailer that talks to a mail server, and a Lender that accepts it through its constructor (constructor injection with a default, which is the dependency-inversion form covered in SOLID and Design Patterns):

# frozen_string_literal: true

# Sends a message to a borrower. In production this would talk to an SMTP server.
class Mailer
  def deliver(to:, subject:)
    raise NotImplementedError, "#{self.class} talks to a real mail server"
  end
end
# frozen_string_literal: true

require "shelf"
require_relative "mailer"

# Lends books from a catalog and tells the borrower about it.
class Lender
  def initialize(catalog:, mailer: Mailer.new)
    @catalog = catalog
    @mailer = mailer
  end

  def lend(isbn, to:)
    book = @catalog.find { |candidate| candidate.isbn == isbn }
    return nil unless book

    loan = Shelf::Loan.new(book, state: :lent)
    @mailer.deliver(to:, subject: "You borrowed #{book.title}")
    loan
  end
end

The spec replaces the Mailer with a verifying double, stubs deliver so it succeeds, and checks afterwards that it was called:

# frozen_string_literal: true

require "lender"

RSpec.describe Lender do
  subject(:lender) { described_class.new(catalog:, mailer:) }

  let(:book) { build_book }
  let(:catalog) { Shelf::Catalog.new(store: Shelf::Store::Memory.new).add(book) }
  let(:mailer) { instance_double(Mailer, deliver: true) }

  describe "#lend" do
    context "when the book is in the catalog" do
      it "returns a lent loan" do
        loan = lender.lend(book.isbn, to: "reader@example.com")

        expect(loan).to have_attributes(book:, state: :lent)
      end

      it "mails the borrower exactly once" do
        lender.lend(book.isbn, to: "reader@example.com")

        expect(mailer).to have_received(:deliver)
          .with(to: "reader@example.com", subject: a_string_including("Ruby Basics"))
          .once
      end
    end

    context "when the book is unknown" do
      let(:unknown) { Shelf::Isbn.new("978-1-86197-876-9") }

      it "returns nil and sends nothing" do
        expect(lender.lend(unknown, to: "reader@example.com")).to be_nil
        expect(mailer).not_to have_received(:deliver)
      end
    end

    context "when mail delivery fails" do
      before { allow(mailer).to receive(:deliver).and_raise(IOError, "SMTP down") }

      it "lets the error reach the caller" do
        expect { lender.lend(book.isbn, to: "reader@example.com") }
          .to raise_error(IOError, "SMTP down")
      end
    end
  end

  describe "with the expect-to-receive style" do
    it "states the expectation before acting" do
      expect(mailer).to receive(:deliver).with(to: "a@example.com", subject: /borrowed/)

      lender.lend(book.isbn, to: "a@example.com")
    end
  end
end
Output (--order defined)
Lender
  #lend
    when the book is in the catalog
      returns a lent loan
      mails the borrower exactly once
    when the book is unknown
      returns nil and sends nothing
    when mail delivery fails
      lets the error reach the caller
  with the expect-to-receive style
    states the expectation before acting

The spec uses these pieces of the rspec-mocks API:

Construct Meaning

double("name", message: value)

A pure double that responds only to the messages it is given. Any other message raises. Good for a collaborator whose class does not exist yet; unsafe once it does, because nothing checks the double against the real interface.

instance_double(Mailer, deliver: true)

A verifying double: it only accepts messages that Mailer instances implement, and with arguments that match the real method’s signature. The name can be a string (instance_double("Mailer")), which is checked only if the constant is loaded, or, with verify_doubled_constant_names, must exist.

class_double(Shelf::Store::Memory, new: store)

The same for the class object (class methods). object_double(obj) verifies against a particular object, which is the right tool for a module or singleton.

spy("name"), instance_spy(Klass), class_spy, object_spy

A double that accepts every message and returns itself (a null object), so the test needs no allow and asserts afterwards with have_received. The verifying variants still reject methods the class does not have.

allow(obj).to receive(:msg) with .with(...), .and_return(a, b), .and_raise(Error, "text"), .and_yield(...), .and_throw, .and_call_original, or a block

A stub: from now on obj.msg answers this way, and no failure results if it is never called. and_return with several values returns them in sequence and repeats the last. A block receives the arguments and computes the answer.

expect(obj).to receive(:msg) with .with(...), .once, .twice, .exactly(n).times, .at_least(:once), .ordered

A message expectation (a mock): the example fails at its end if the message was not received as described. It must be written before the action.

expect(obj).to have_received(:msg) with the same constraints

A spy assertion, written after the action. It needs the message to have been allowed first (automatic for spies and for the stubbed double above).

receive_messages(a: 1, b: 2), receive_message_chain(:a, :b)

Stub several messages at once, or a chain obj.a.b. A message chain is a design smell (a Law of Demeter violation) and should be a last resort.

Argument matchers

anything, any_args, no_args, instance_of(K), kind_of(K), hash_including(k: v), array_including, a_string_including, having_attributes: any matcher from rspec-expectations also works as an argument matcher

The difference between the last two styles in the sample is where the check lives. With allow and have_received, the arrange step lives in a let or before, the act step is plain code, and the assert step is last, so the example reads in the same order as any other test. With expect(...).to receive, the expectation precedes the action. The first style is usually preferred because a failing spy assertion reports at the end of the example with the real call history, and because the same double can be shared by several examples with different assertions.

What a verifying double catches

The reason to prefer instance_double over double is that it fails when the production code and the double drift apart. If somebody renames Mailer#deliver, a spec that uses a plain double keeps passing against an interface that no longer exists; a spec that uses instance_double(Mailer) fails. These four examples fail on purpose, and the messages show what RSpec tells you (real output, paths trimmed):

# frozen_string_literal: true

require "lender"

RSpec.describe "what verifying doubles catch" do
  it "rejects a method the class does not have" do
    mailer = instance_double(Mailer)
    allow(mailer).to receive(:send_mail)
  end

  it "rejects the wrong keywords" do
    mailer = instance_double(Mailer)
    allow(mailer).to receive(:deliver).with(to: "a@example.com")
  end

  it "reports an unexpected message on a plain double" do
    mailer = double("mailer")
    mailer.deliver(to: "a@example.com", subject: "Hi")
  end

  it "reports an unmet expectation" do
    mailer = instance_double(Mailer)
    expect(mailer).to receive(:deliver).with(to: "a@example.com", subject: "Hi")
  end
end
Output
  1) what verifying doubles catch rejects a method the class does not have
     Failure/Error: allow(mailer).to receive(:send_mail)
       the Mailer class does not implement the instance method: send_mail

  2) what verifying doubles catch rejects the wrong keywords
     Failure/Error: allow(mailer).to receive(:deliver).with(to: "a@example.com")
       Missing required keyword arguments: subject

  3) what verifying doubles catch reports an unexpected message on a plain double
     Failure/Error: mailer.deliver(to: "a@example.com", subject: "Hi")
       #<Double "mailer"> received unexpected message :deliver with ({subject: "Hi", to: "a@example.com"})

  4) what verifying doubles catch reports an unmet expectation
     Failure/Error: expect(mailer).to receive(:deliver).with(to: "a@example.com", subject: "Hi")

       (InstanceDouble(Mailer) (anonymous)).deliver({subject: "Hi", to: "a@example.com"})
           expected: 1 time with arguments: ({subject: "Hi", to: "a@example.com"})
           received: 0 times

Verification happens when the class is loaded, which is why the Mailer is require`d: with the class absent, a string-named `instance_double is a plain double. Argument checking uses the real method’s parameters, so the keyword mistake in the second example is reported with Ruby’s own wording (see Methods and Parameters for how Ruby reports missing keywords).

The other kinds of double

A second spec file exercises the remaining constructs against the shelf classes:

# frozen_string_literal: true

RSpec.describe "kinds of test doubles" do
  let(:book) { build_book }

  it "uses a pure double with allow" do
    clock = double("clock", now: Time.at(0).utc)

    expect(clock.now.year).to eq(1970)
  end

  it "uses a spy that records everything" do
    audit = spy("audit")

    audit.record(:lent)

    expect(audit).to have_received(:record).with(:lent)
  end

  it "uses class_double for a class-level collaborator" do
    store_class = class_double(Shelf::Store::Memory, new: Shelf::Store::Memory.new)

    expect(store_class.new.all).to eq([])
  end

  it "computes a return value from the arguments" do
    store = instance_double(Shelf::Store::Memory)
    allow(store).to receive(:add) { |b| "stored #{b.title}" }

    expect(store.add(book)).to eq("stored Ruby Basics")
  end

  it "stubs several messages and returns in sequence" do
    store = instance_double(Shelf::Store::Memory)
    allow(store).to receive(:all).and_return([], [book])

    expect([store.all, store.all, store.all]).to eq([[], [book], [book]])
  end

  it "wraps a real method with and_call_original" do
    memory = Shelf::Store::Memory.new
    allow(memory).to receive(:add).and_call_original

    memory.add(book)

    expect(memory).to have_received(:add).with(book)
    expect(memory.all).to eq([book])
  end

  it "stubs a constant for one example" do
    stub_const("Shelf::DEFAULT_LOAN_DAYS", 21)

    expect(Shelf::DEFAULT_LOAN_DAYS).to eq(21)
  end

  it "matches arguments loosely" do
    store = instance_spy(Shelf::Store::Memory)

    store.add(book)

    expect(store).to have_received(:add).with(a_kind_of(Shelf::Book))
    expect(store).to have_received(:add).with(having_attributes(title: "Ruby Basics"))
  end
end
Output (--order defined)
kinds of test doubles
  uses a pure double with allow
  uses a spy that records everything
  uses class_double for a class-level collaborator
  computes a return value from the arguments
  stubs several messages and returns in sequence
  wraps a real method with and_call_original
  stubs a constant for one example
  matches arguments loosely

Notes on the less obvious lines:

  • allow(memory).to receive(:add).and_call_original stubs a method on a real object (a partial double) and still runs the real implementation, so the example can spy on a real collaborator. With verify_partial_doubles = true, stubbing a method that the object does not have fails with a message of the form #<Shelf::Store::Memory:0x... @books=[]> does not implement: nope. Leave that option on.

  • stub_const (and hide_const) replaces or removes a constant for one example and restores it afterwards. It is the supported way to swap a configuration constant; assigning the constant in the spec triggers an "already initialized constant" warning and leaks.

  • Everything done to a double or partial double is undone at the end of each example. Stubs on real objects are removed when the example finishes, which is why the Store::Memory instances in different examples never see each other’s stubs.

  • Time is a collaborator too. Instead of stubbing Time.now, inject a clock (Time itself responds to now), or use a time-travel gem such as Timecop (community; not installed for this page); stubbing Time.now globally with allow(Time).to receive(:now) works but ties the test to the call.

When not to use a double

A double verifies how the code talks to a collaborator, so every double adds coupling to the implementation. The rules of thumb that avoid brittle suites are:

  • Double only what is slow, non-deterministic or has side effects: the network, the clock, the file system, a mail server, a payment API. Use real value objects (Isbn, Book, Author) and cheap fakes (Store::Memory) as they are.

  • Never double the object under test. If a spec stubs a method of its own subject, the code is telling you that the subject has two jobs and should be split.

  • Stub queries (methods that answer a question) freely and assert on commands (methods that cause an effect) only when the effect is the contract, as with deliver above.

  • Prefer asserting on observable state to asserting on messages. expect(store.all).to contain_exactly(book) survives a refactor that expect(store).to have_received(:add) does not.

  • Keep a few integration examples that wire the real classes together, so a double that has drifted from reality is caught by a test that does not use it.

Shared examples and shared context

When several classes must satisfy the same contract, an interface in the duck-typing sense (see SOLID and Design Patterns), shared_examples states the contract once and each implementation proves it. A store has to start empty, return itself from add so calls chain, keep what was added and not leak its internal array; any future store (a file-backed one, say) runs the same examples:

# frozen_string_literal: true

RSpec.shared_examples "a book store" do
  let(:book) { build_book }

  it "starts empty" do
    expect(store.all).to be_empty
  end

  it "returns itself from #add so calls can chain" do
    expect(store.add(book)).to equal(store)
  end

  it "keeps what was added" do
    store.add(book)
    expect(store.all).to contain_exactly(book)
  end

  it "hands out a copy that cannot corrupt the store" do
    store.add(book)
    store.all.clear
    expect(store.all.size).to eq(1)
  end
end
# frozen_string_literal: true

require_relative "../support/store_examples"

RSpec.describe Shelf::Store::Memory do
  it_behaves_like "a book store" do
    let(:store) { described_class.new }
  end
end
Output (--order defined)
Shelf::Store::Memory
  behaves like a book store
    starts empty
    returns itself from #add so calls can chain
    keeps what was added
    hands out a copy that cannot corrupt the store

The pieces are:

  • RSpec.shared_examples "name" defines a named set of examples at top level; it is available once the file is loaded, which is why store_spec.rb requires it (a spec/support file can be required from spec_helper.rb, as the helpers above are, to avoid the explicit require).

  • it_behaves_like "name" runs the examples in a nested group, so a let given in the block does not leak into the host group, and the output shows "behaves like a book store". include_examples "name" runs them in the current group, which is what you need when the examples share the host’s let and before. Choose it_behaves_like by default.

  • Parameters are passed after the name and arrive as block parameters: it_behaves_like "a store", Shelf::Store::Memory and RSpec.shared_examples("a store") { |store_class| ... }. A let defined in the including block is also visible to the shared examples, as store is here.

  • RSpec.shared_context "name" shares setup (let, hooks, helpers) instead of examples, and include_context "name" pulls it in. With shared_context_metadata_behavior = :apply_to_host_groups, metadata given to the context (RSpec.shared_context "with a clock", :clock) is applied to the group that includes it.

Tags, metadata and the command line

Every group and example has metadata, a hash: describe "x", :slow, owner: "billing" stores slow: true and owner: "billing", a bare symbol standing for symbol: true. Metadata is how an example is selected, how hooks are scoped (before(:each, :db)) and how a shared context is applied. The spec_helper.rb above excludes the :slow tag unless the SLOW environment variable is set:

# frozen_string_literal: true

RSpec.describe "Catalog at scale" do
  it "scans a large catalog", :slow do
    store = Shelf::Store::Memory.new
    5_000.times { |i| store.add(build_book("Book #{i}")) }

    expect(Shelf::Catalog.new(store:).count).to eq(5_000)
  end

  it "stays quick for a small catalog" do
    expect(Shelf::Catalog.new(store: Shelf::Store::Memory.new).count).to eq(0)
  end
end
Output of a default run (--order defined)
Run options: exclude {slow: true}
...
  stays quick for a small catalog
Output of rspec --tag slow
Run options: include {slow: true}
...
  scans a large catalog

A command-line inclusion (--tag slow) overrides the exclusion that the configuration declared, which is why the second run executes the slow example. --tag '~slow' excludes a tag from the command line. The same metadata approach drives performance tests, which the page on Performance, Benchmarking and Profiling tags so that they stay out of the default run.

The options worth knowing, all checked against rspec --help of 3.13 and, where noted, run:

Command Effect

rspec / bundle exec rspec

Runs everything under spec/. Exit status 0 on success and non-zero on a failure, which is what CI needs.

rspec spec/shelf/isbn_spec.rb

Runs one file; several paths and directories may be given.

rspec spec/shelf/isbn_spec.rb:12

Runs the example or group that contains line 12. Several lines may be given as :12:20. The run prints include {locations: {...}} in its header.

rspec -e "bad check digit"

Runs the examples whose full description matches the string (--example); -E / --example-matches takes a regular expression.

rspec --tag slow and --tag '~slow'

Includes or excludes tagged examples; --tag owner:billing matches a value.

rspec --format documentation / progress

Chooses the output. progress (dots) is the default; documentation prints the nested tree used in this page. json, html and a failure-list formatter are also built in; repeat --format with --out to write several at once.

rspec --dry-run

Prints the formatter output without running any example: a quick way to list what a filter selects.

rspec --seed 1234 / --order defined

Replays a random order or runs in file order.

rspec --only-failures / --next-failure (-n)

Runs only the examples that failed in the last run, or the first failure with --fail-fast --order defined. Both need example_status_persistence_file_path (set above).

rspec --fail-fast[=N]

Stops after the first (or N-th) failure.

rspec --profile 2

Prints the two slowest examples and groups.

rspec --bisect

Repeatedly reruns the suite to find the smallest set of examples that reproduces an order-dependent failure.

rspec --require ./file.rb (-r), -I dir

Requires a file before the run, or adds a directory to the load path.

Options can also live in .rspec (project, committed) and .rspec-local (personal, normally ignored) and in ~/.rspec; the command line wins. A Rake task runs the suite from rake and integrates it with a gem’s default task (see the Rake section of Creating and Publishing Gems, and Rake):

# frozen_string_literal: true

require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec) do |task|
  task.rspec_opts = "-I ../shelf/lib --format progress"
end

task default: :spec

Running rake in that project ran the full suite of this page. Failures are never silent: a non-zero exit status makes the Rake task, and so CI, fail.

Test-driven development with RSpec

Test-driven development (TDD) is a loop of three steps. Red: write one failing example that states the next small behavior. Green: write the least code that makes it pass. Refactor: improve the code while the examples stay green. The loop works because the spec is written first as a design tool: the example is the first caller of the code, so awkward interfaces show up before they exist. RSpec supports the loop with fast feedback (--next-failure, --only-failures, --fail-fast) and readable failure output.

The example computes the late fee of a loan in cents: free on the due date, 25 cents per day after it, capped at 1000 cents. The three examples are written first.

# frozen_string_literal: true

require "late_fee"

RSpec.describe LateFee do
  describe ".for" do
    it "is free on or before the due date" do
      expect(described_class.for(days_late: 0)).to eq(0)
    end

    it "charges 25 cents a day after the due date" do
      expect(described_class.for(days_late: 3)).to eq(75)
    end

    it "never exceeds the 1000 cent cap" do
      expect(described_class.for(days_late: 400)).to eq(1000)
    end
  end
end

The first implementation is the simplest thing that passes the first example; the other two fail. That is red:

# frozen_string_literal: true

# Late fees in cents.
module LateFee
  def self.for(days_late:) = 0
end
Output (red, progress format)
.FF

Failures:

  1) LateFee.for charges 25 cents a day after the due date
     Failure/Error: expect(described_class.for(days_late: 3)).to eq(75)

       expected: 75
            got: 0

       (compared using ==)

  2) LateFee.for never exceeds the 1000 cent cap
     Failure/Error: expect(described_class.for(days_late: 400)).to eq(1000)

       expected: 1000
            got: 0

       (compared using ==)

Finished in 0.00247 seconds (files took 0.03658 seconds to load)
3 examples, 2 failures

The implementation that makes all three pass is green; the constants are the refactoring, and the examples guard them:

# frozen_string_literal: true

# Late fees in cents.
module LateFee
  RATE = 25
  CAP = 1000

  def self.for(days_late:) = [days_late * RATE, CAP].min
end
Output (green)
...

Finished in 0.00176 seconds (files took 0.04496 seconds to load)
3 examples, 0 failures

A habit that makes the loop honest is to watch the example fail for the right reason before making it pass: the expected: 75 got: 0 message above proves that the example is checking the thing it claims to check. A test that has never been red proves nothing. The same rhythm applies to refactoring existing code: write a characterization spec that pins the present behavior, change the structure, and keep the spec green; see Idioms, Style and Clean Code. Specs written as a design aid also tend to push toward the shape this section’s Lender has: explicit collaborators passed in, small public methods, results returned rather than printed.

Ecosystem around RSpec

These tools are widely used with RSpec but are separate community gems. They were not installed for this page, so the descriptions below come from their official documentation and none of their code was run:

Tool What it does

FactoryBot (factory_bot) https://github.com/thoughtbot/factory_bot

Defines named factories for test data (build(:book), create(:loan, state: :lent)) instead of fixtures. A plain helper such as build_book above is often enough for value objects; factories pay off for large object graphs and database records.

SimpleCov (simplecov) https://github.com/simplecov-ruby/simplecov

Code coverage report built on the standard library’s Coverage. It is started at the very top of spec_helper.rb, before the code under test is required. The page on Testing with Minitest covers coverage in the Minitest context; the setup is identical.

rubocop-rspec https://github.com/rubocop/rubocop-rspec

RuboCop cops for spec style: example length, nested groups, let misuse, focus left in, message expectation style and many more.

WebMock and VCR

Block real HTTP requests during tests (WebMock, https://github.com/bblimke/webmock) and record and replay them (VCR); the alternative to doubling Net::HTTP, see Networking and HTTP.

rspec-rails

The RSpec integration for Rails applications (https://github.com/rspec/rspec-rails). Rails itself is out of scope in this section.

RSpec or Minitest

Both are mature, both run in CI, and both can test any Ruby code. The decision is mostly about style and team habit, and it can differ per project. The comparison below is about the frameworks themselves:

Aspect RSpec Minitest

Distribution

Community gem (rspec), added to the Gemfile

Bundled gem; on a fresh Ruby 4.0 it is available, and Minitest 6.0.0 is the version installed for this page

Style

Nested describe/context/it with expect(...).to matcher; a rich DSL

Plain Ruby classes (Minitest::Test) with assert_* methods, or the lighter Minitest::Spec DSL (describe, it, _(x).must_equal)

Learning curve

More concepts (let, subject, hooks, shared examples, many matchers), more magic

Almost none beyond Ruby itself; failures point at ordinary methods

Failure output

Excellent messages from matchers, aggregate_failures, diffs

Good and simple; custom assertions are plain methods

Test doubles

Built in (rspec-mocks): verifying doubles, spies, partial doubles

Minitest 6 does not ship them: require "minitest/mock" raises LoadError: cannot load such file — minitest/mock here; see the Minitest page for where mocks and stubs live in Minitest 6. Many projects use plain hand-written fakes instead

Filtering and tooling

Tags, line numbers, --only-failures, --bisect, many formatters

Name patterns (-n), plugins such as minitest-reporters; fewer options

Speed

A little heavier to load

Very light; part of what makes rake test fast

Ecosystem

Largest set of integrations and learning material

Smallest dependency footprint; a bundled gem, used by many gems

A reasonable default: choose Minitest for libraries that want few dependencies and for teams that prefer plain Ruby, and RSpec when the team values readable specification output and a big matcher and double toolbox, or already works in a codebase that uses it. Do not mix them in one test run: each has its own runner and configuration. The two can coexist in one repository only if each owns separate directories (test/ and spec/).

Where older material is outdated

Tutorials and books on RSpec and on testing in Ruby, including the chapters on RSpec versus Minitest, FactoryBot, SimpleCov and test structure in the books consulted for this section, were written for older Ruby versions. When you meet them, check these points:

  • The should syntax (result.should eq(1), object.should_receive(:msg), stub(:msg)) is the RSpec 2 style. RSpec 3 prefers expect(result).to eq(1) and expect(object).to receive(:msg), and allow(object).to receive(:msg) for stubs. The should syntax now requires explicit enabling (see the matchers section for the real error and deprecation message), and disable_monkey_patching! removes it.

  • Bare describe at top level needs monkey patching. Use RSpec.describe.

  • Plain double for everything: verifying doubles (instance_double, class_double, verify_partial_doubles) were added in RSpec 3 and catch a class of silent failures that tutorials from the RSpec 2 era miss.

  • Old inspect output. Error messages and hashes in older material show the receiver as nil:NilClass with a backtick before the method name, and Hash#inspect as {:name=>"Ico"}; on Ruby 4.0 they read undefined method 'x' for nil and {name: "Ico"} (Ruby 3.3 changed the receiver wording and 3.4 changed the quotes and the Hash#inspect format).

  • Version histories. Minitest::Mock and stub are not available out of the box in Minitest 6 (shown above), and Set, Data and the numbered it block parameter are everyday tools in 4.0 specs. A Data value object compares by value, so expect(isbn).to eq(other_isbn) works as the #== example above shows.

  • Mocking as the default. The strict message-expectation style popularised in older guides is now a minority view; the community advice summarised above is to double boundaries and to assert on state wherever possible.

  • Random order. Older tutorials ran specs in defined order; config.order = :random is now the norm, so hidden dependencies between examples are found early.

For the Rails-era rails_helper.rb / spec_helper.rb split, and FactoryBot’s create strategy that writes to a database, see the documentation of those gems; they are outside the scope of this framework-independent section.

References