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
Source: RSpec Core: 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
Source: RSpec Core 3.13 features
The settings do the following:
| Setting | Effect |
|---|---|
|
Removes the global |
|
Runs examples in a random order and prints the seed ( |
|
Configure |
|
Makes a stub on a real object (a partial double) fail unless the object really has that method with a compatible signature. |
|
Makes |
|
Skips examples tagged |
|
Records the last result of each example in a file so that |
|
|
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
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:
-
.namefor a class method and#namefor 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_classis the class passed to the outermostdescribe. 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
itstates the result.context "with a bad check digit"followed byit "raises ArgumentError..."reads as a rule. -
One behavior per example. An example may contain several
expectcalls 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
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
Source: RSpec Core: hooks
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 inafter(:context). Instance variables set inbefore(: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 theletonly what is incidental. Overriding aletin acontextis the idiom for the variable that the context is about. -
Avoid
let!andbeforefor values that only some examples use; lazyletis 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 |
|
Type and shape |
|
x |
… }+ |
Truthiness and predicates |
|
Collections |
|
Comparison |
|
Blocks |
|
Compound |
|
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
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
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
Source: RSpec Mocks 3.13 features and verifying doubles
The spec uses these pieces of the rspec-mocks API:
| Construct | Meaning |
|---|---|
|
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. |
|
A verifying double: it only accepts messages that |
|
The same for the class object (class methods). |
|
A double that accepts every message and returns itself (a null object), so the test needs no |
|
A stub: from now on |
|
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. |
|
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). |
|
Stub several messages at once, or a chain |
Argument matchers |
|
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
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
Source: RSpec Mocks: verifying doubles
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
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
Source: RSpec Mocks: spies, allowing messages and expecting messages
Notes on the less obvious lines:
-
allow(memory).to receive(:add).and_call_originalstubs a method on a real object (a partial double) and still runs the real implementation, so the example can spy on a real collaborator. Withverify_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(andhide_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::Memoryinstances in different examples never see each other’s stubs. -
Time is a collaborator too. Instead of stubbing
Time.now, inject a clock (Timeitself responds tonow), or use a time-travel gem such as Timecop (community; not installed for this page); stubbingTime.nowglobally withallow(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
deliverabove. -
Prefer asserting on observable state to asserting on messages.
expect(store.all).to contain_exactly(book)survives a refactor thatexpect(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
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
Source: RSpec Core: shared examples
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 whystore_spec.rbrequires it (aspec/supportfile can be required fromspec_helper.rb, as the helpers above are, to avoid the explicit require). -
it_behaves_like "name"runs the examples in a nested group, so aletgiven 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’sletandbefore. Chooseit_behaves_likeby default. -
Parameters are passed after the name and arrive as block parameters:
it_behaves_like "a store", Shelf::Store::MemoryandRSpec.shared_examples("a store") { |store_class| ... }. Aletdefined in the including block is also visible to the shared examples, asstoreis here. -
RSpec.shared_context "name"shares setup (let, hooks, helpers) instead of examples, andinclude_context "name"pulls it in. Withshared_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
Run options: exclude {slow: true}
...
stays quick for a small catalog
rspec --tag slowRun options: include {slow: true}
...
scans a large catalog
Source: RSpec Core: tags and metadata
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 |
|---|---|
|
Runs everything under |
|
Runs one file; several paths and directories may be given. |
|
Runs the example or group that contains line 12. Several lines may be given as |
|
Runs the examples whose full description matches the string ( |
|
Includes or excludes tagged examples; |
|
Chooses the output. |
|
Prints the formatter output without running any example: a quick way to list what a filter selects. |
|
Replays a random order or runs in file order. |
|
Runs only the examples that failed in the last run, or the first failure with |
|
Stops after the first (or N-th) failure. |
|
Prints the two slowest examples and groups. |
|
Repeatedly reruns the suite to find the smallest set of examples that reproduces an order-dependent failure. |
|
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
Source: RSpec Core: rake task
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
.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
...
Finished in 0.00176 seconds (files took 0.04496 seconds to load)
3 examples, 0 failures
Source: RSpec Core 3.13 features
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 ( |
Defines named factories for test data ( |
SimpleCov ( |
Code coverage report built on the standard library’s |
|
RuboCop cops for spec style: example length, nested groups, |
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 |
|
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 ( |
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 |
Plain Ruby classes ( |
Learning curve |
More concepts ( |
Almost none beyond Ruby itself; failures point at ordinary methods |
Failure output |
Excellent messages from matchers, |
Good and simple; custom assertions are plain methods |
Test doubles |
Built in ( |
Minitest 6 does not ship them: |
Filtering and tooling |
Tags, line numbers, |
Name patterns ( |
Speed |
A little heavier to load |
Very light; part of what makes |
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
shouldsyntax (result.should eq(1),object.should_receive(:msg),stub(:msg)) is the RSpec 2 style. RSpec 3 prefersexpect(result).to eq(1)andexpect(object).to receive(:msg), andallow(object).to receive(:msg)for stubs. Theshouldsyntax now requires explicit enabling (see the matchers section for the real error and deprecation message), anddisable_monkey_patching!removes it. -
Bare
describeat top level needs monkey patching. UseRSpec.describe. -
Plain
doublefor 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:NilClasswith a backtick before the method name, andHash#inspectas{:name=>"Ico"}; on Ruby 4.0 they readundefined method 'x' for niland{name: "Ico"}(Ruby 3.3 changed the receiver wording and 3.4 changed the quotes and theHash#inspectformat). -
Version histories.
Minitest::Mockandstubare not available out of the box in Minitest 6 (shown above), andSet,Dataand the numbereditblock parameter are everyday tools in 4.0 specs. ADatavalue object compares by value, soexpect(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 = :randomis 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
-
RSpec (home), documentation index and the RSpec repository
-
RSpec Core 3.13 features (example groups, hooks,
subject, metadata, tags, command line, shared examples, shared context), hooks, subject, metadata, tags, only failures and bisect -
RSpec Expectations 3.13 features, built-in matchers, custom matchers and aggregating failures
-
RSpec Mocks 3.13 features, verifying doubles, allowing messages, expecting messages, spies, partial test doubles and null object doubles
-
Better Specs (community guidelines for writing specs)
-
FactoryBot, SimpleCov, rubocop-rspec, WebMock and rspec-rails (not installed for this page; described from their official documentation)