Writing RSpec-Style Tests
RSpec is the most widely used testing library in the Ruby ecosystem. Instead of writing raw assert statements, RSpec gives you a readable, English-like domain-specific language (DSL) — describe, it, and expect — for describing how your code should behave. Tests written this way double as living documentation: reading a spec file tells you what a class is supposed to do, not just how to invoke it. Understanding RSpec is essential for working on almost any real-world Ruby or Rails codebase.
Overview / How It Works
RSpec is not a language feature — it’s a gem (actually several gems: rspec-core, rspec-expectations, and rspec-mocks, bundled together as the rspec meta-gem). Everything you see in a spec file is ordinary Ruby: describe, context, and it are just methods that accept a string and a block. There is no special parser or new syntax to learn — it’s all blocks and method calls, which is possible because, as with everything in Ruby, blocks are first-class values that can be captured and executed later rather than immediately.
Here’s what actually happens under the hood. Calling describe SomeClass do ... end defines a new subclass of RSpec::Core::ExampleGroup and evaluates the block in that class’s context. Inside it, each it "does something" do ... end call registers an example — it does not run the block immediately, it just stores it for later. before and after hooks are stored the same way. Only when you actually run the suite (via the rspec command, or via require "rspec/autorun" for a standalone file) does RSpec instantiate a fresh object of the example group class for every single it block, run any before hooks, run the example, then any after hooks. A fresh instance per example is what gives you test isolation: instance variables and let-memoized values from one example never leak into another (unless you fight that isolation with before(:all) — more on that in Common Mistakes).
The expect(actual).to matcher syntax works through method chaining. expect(actual) wraps your value in an internal target object. Calling .to on it takes a matcher — an object built by calling something like eq(5) — and calls matcher.matches?(actual). If that returns false, RSpec raises a failure and records it; if true, execution just continues silently. This is a crucial point: nothing fails unless an expectation explicitly says so. A block that merely evaluates a boolean expression without wrapping it in expect asserts nothing at all, no matter what the expression evaluates to.
RSpec also supports pattern-matching-free destructuring via mocks: double creates a fake object standing in for a real collaborator, and allow/expect(...).to have_received let you verify that a method was called with specific arguments, without needing the real dependency (a database, network call, mail server) to exist during the test.
Syntax
The general shape of an RSpec spec file looks like this:
describe SomeClass do
context "when some condition holds" do
before do
# setup that runs before every example in this block
end
it "describes an expected behavior" do
expect(actual_value).to eq(expected_value)
end
end
end
| Term | Purpose |
|---|---|
describe |
Groups examples for a class or feature; can be nested arbitrarily |
context |
An alias for describe, used by convention to describe a specific situation or state (“when the cart is empty”) |
it |
Defines one example (one test case) with a description and a block |
before / after |
Hooks that run before/after each example (:each, the default) or once for the whole group (:all/:context) |
let(:name) { ... } |
Defines a lazily-evaluated, memoized helper method — the block only runs the first time name is called within an example, and the result is cached for that example only |
subject / subject(:name) |
Defines the primary object under test; works like let but has a conventional meaning of “the thing this group is testing” |
expect(x).to matcher |
Asserts that x satisfies matcher; use not_to for the negative |
eq, eql, equal |
Value equality (==), typed value equality, and object identity (equal?) respectively |
be_truthy, be_falsey, be_nil |
Match Ruby’s truthiness rules — remember only nil and false are falsy in Ruby, so 0 and "" satisfy be_truthy |
raise_error(ErrorClass) |
Matches against a block, asserting it raises the given exception |
include, be_empty, respond_to |
Common matchers for collections and object interfaces |
Examples
Example 1: A basic spec with expectations and a raised error
require 'rspec/autorun'
class Calculator
def add(a, b) = a + b
def divide(a, b)
raise ZeroDivisionError, "cannot divide by zero" if b.zero?
a / b
end
end
describe Calculator do
let(:calculator) { Calculator.new }
it "adds two numbers" do
expect(calculator.add(2, 3)).to eq(5)
end
it "raises an error when dividing by zero" do
expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
end
Output:
..
Finished in 0.002 seconds (files took 0.01 seconds to load)
2 examples, 0 failures
Each passing example prints a dot; the summary line at the end reports how many examples ran and how many failed. The exact timing numbers will vary every run since they reflect real elapsed time — what matters is the dot count and the final “0 failures”. Note the second example wraps the risky call in { ... } so expect receives a block rather than a value; that’s required whenever you’re asserting that calling something raises an error, since RSpec must control exactly when the code runs.
Example 2: context, before hooks, and a subject
require 'rspec/autorun'
class Stack
def initialize
@items = []
end
def push(item)
@items.push(item)
self
end
def pop
raise "stack is empty" if @items.empty?
@items.pop
end
def empty? = @items.empty?
def size = @items.size
end
describe Stack do
subject(:stack) { Stack.new }
context "when newly created" do
it "is empty" do
expect(stack).to be_empty
end
it "has a size of zero" do
expect(stack.size).to eq(0)
end
end
context "when items have been pushed" do
before do
stack.push(1)
stack.push(2)
end
it "reports the correct size" do
expect(stack.size).to eq(2)
end
it "pops items in last-in-first-out order" do
expect(stack.pop).to eq(2)
expect(stack.pop).to eq(1)
end
end
context "when empty" do
it "raises an error on pop" do
expect { stack.pop }.to raise_error("stack is empty")
end
end
end
Output:
.....
Finished in 0.004 seconds (files took 0.01 seconds to load)
5 examples, 0 failures
be_empty works because RSpec’s “predicate matchers” translate be_empty into a call to stack.empty? and check that it returns a truthy value — this is a general rule: be_xyz calls xyz? on the actual value. Each context block gets a brand-new stack from subject because subject, like let, is re-evaluated fresh for every example — the pushes in one context never bleed into another.
Example 3: mocking a collaborator with a double
require 'rspec/autorun'
class OrderNotifier
def initialize(mailer)
@mailer = mailer
end
def notify(order)
return false if order[:email].nil?
@mailer.send_email(order[:email], "Your order ##{order[:id]} has shipped!")
true
end
end
describe OrderNotifier do
let(:mailer) { double("Mailer") }
let(:notifier) { OrderNotifier.new(mailer) }
it "sends an email when the order has an address" do
allow(mailer).to receive(:send_email)
order = { id: 42, email: "customer@example.com" }
expect(notifier.notify(order)).to eq(true)
expect(mailer).to have_received(:send_email).with(
"customer@example.com",
"Your order #42 has shipped!"
)
end
it "does not attempt to email when there is no address" do
order = { id: 43, email: nil }
expect(notifier.notify(order)).to eq(false)
end
end
Output:
..
Finished in 0.003 seconds (files took 0.01 seconds to load)
2 examples, 0 failures
double("Mailer") creates a lightweight fake object that isn’t a real Mailer at all — it only responds to methods you explicitly stub with allow(...).to receive(...). This lets the spec verify how OrderNotifier uses its collaborator (was send_email called, and with what arguments?) without needing a real mail server. This technique is invaluable for isolating the unit under test from slow or unpredictable external dependencies.
How It Works Step by Step
When you run a spec file, RSpec proceeds through a predictable sequence:
- The file is loaded (via
require 'rspec/autorun'for a standalone script, or via therspeccommand line tool for a project), and everydescribe/contextblock executes immediately — but only to register nested groups,itexamples, and hooks. No test logic runs yet. - Once everything is loaded, the runner asks each example group, in turn, to run its examples.
- For each
itblock, RSpec instantiates a fresh instance of that group. This is whylet,subject, and instance variables set inbeforeare private to that one example. - Any
beforehooks (outer groups first, then inner) run against that fresh instance. - The example’s own block runs. Every
expect(...).to matchercall inside it either passes silently or raises a failure that RSpec catches and records — it does not stop the whole suite. - Any
afterhooks run (inner groups first, then outer), even if the example failed. - Once every example has run, the formatter prints the summary: a dot per pass, an
Fper failure, followed by details of any failures and a final count.
Common Mistakes
Mistake 1: writing an assertion-free “test”
require 'rspec/autorun'
describe "Calculator (no real assertion)" do
it "adds two numbers correctly" do
1 + 1 == 3
end
end
Output:
.
Finished in 0.001 seconds (files took 0.01 seconds to load)
1 example, 0 failures
This passes even though 1 + 1 == 3 is false! The block just evaluates a boolean expression and discards it — nothing calls expect, so RSpec never inspects the result. This is a genuinely dangerous mistake because the test looks meaningful but verifies nothing. Always wrap the value you care about in expect(...).to:
require 'rspec/autorun'
describe "Calculator (with a real assertion)" do
it "adds two numbers correctly" do
expect(1 + 1).to eq(2)
end
end
Output:
.
Finished in 0.001 seconds (files took 0.01 seconds to load)
1 example, 0 failures
Mistake 2: sharing mutable state with before(:all)
require 'rspec/autorun'
describe "Order queue (shared state bug)" do
before(:all) do
@queue = []
end
it "adds an item" do
@queue << "item1"
expect(@queue.size).to eq(1)
end
it "starts with a fresh queue" do
expect(@queue.size).to eq(0)
end
end
Output:
.F
Failures:
1) Order queue (shared state bug) starts with a fresh queue
Failure/Error: expect(@queue.size).to eq(0)
expected: 0
got: 1
2 examples, 1 failure
before(:all) runs its block only once for the whole group, sharing the same @queue array across every example instead of resetting it. The first example mutates it, and the second example inherits that mutation — a classic source of order-dependent, flaky tests. The fix is to use before(:each) (the default when you just write before), which runs fresh for every example:
require 'rspec/autorun'
describe "Order queue (fixed)" do
before(:each) do
@queue = []
end
it "adds an item" do
@queue << "item1"
expect(@queue.size).to eq(1)
end
it "starts with a fresh queue" do
expect(@queue.size).to eq(0)
end
end
Output:
..
2 examples, 0 failures
Mistake 3: confusing eq with equal
require 'rspec/autorun'
describe "String identity confusion" do
it "assumes equal-value strings are the same object" do
a = "ruby"
b = "ruby"
expect(a).to equal(b)
end
end
Output:
F
Failures:
1) String identity confusion assumes equal-value strings are the same object
Failure/Error: expect(a).to equal(b)
expected "ruby" and "ruby" to be the same object, but they were not (different object_id)
1 example, 1 failure
This mirrors a core Ruby distinction: == (value equality, used by eq) versus equal? (object identity, used by equal). Two separate string literals with the same content are still two different objects in memory. Use eq when you care about the value, and reserve equal for the rare cases where object identity itself matters:
require 'rspec/autorun'
describe "String value equality" do
it "compares equal-value strings using eq" do
a = "ruby"
b = "ruby"
expect(a).to eq(b)
end
it "confirms they remain different objects" do
a = "ruby"
b = "ruby"
expect(a).not_to equal(b)
end
end
Output:
..
2 examples, 0 failures
Best Practices
- Keep each
itfocused on one behavior; a descriptive title (“raises an error when the balance is negative”) should read like a sentence. - Prefer
letandsubjectover instance variables set inbefore— they’re lazy (only computed if used), which keeps unrelated examples fast and avoids stale setup. - Default to
before(:each)(justbefore) for anything mutable; reservebefore(:all)for genuinely expensive, read-only setup, and never mutate what it creates. - Use
contextto describe a state or condition (“when the cart is empty”, “when the user is an admin”) anddescribefor the class or method under test — the distinction is convention, not a language rule, but keeping it consistent makes specs easier to scan. - Use
doubleandallow/expect(...).to have_receivedto isolate the unit under test from slow or unreliable collaborators (databases, HTTP calls, mail servers). - Organize real projects with a
spec/directory, one*_spec.rbfile per class, and a sharedspec_helper.rbloaded from a.rspecfile — install withgem install rspecor addgem "rspec"to your Gemfile, then runbundle exec rspec --initonce to scaffold it. - Run the whole suite with
bundle exec rspec(or plainrspecif installed globally) rather thanrubyon individual files, so hooks, shared configuration, and custom matchers load correctly.
Practice Exercises
- Write a
FizzBuzzclass with acall(n)method that returns"Fizz"for multiples of 3,"Buzz"for multiples of 5,"FizzBuzz"for multiples of both, and the number’s string form otherwise. Write a spec using nestedcontextblocks for each of the four cases, with at least oneitper context. - Write a
Temperatureclass with acelsius_to_fahrenheit(c)method. Write a spec usingsubjectandlet. Then deliberately introduce abefore(:all)block that mutates an instance variable across two examples, run it mentally, and predict which example fails and why — then rewrite it withbefore(:each)to fix it. - Write a
PaymentProcessorclass whosecharge(amount)method delegates to an injectedgatewayobject’scharge(amount)method. Write a spec usingdoubleandallow(...).to receiveto verify thatgateway.chargeis called with the correct amount, without implementing a real gateway.
Summary
- RSpec is a Ruby gem, not a language feature —
describe,it,context, andexpectare ordinary methods that accept blocks, built on Ruby’s DSL-friendly syntax. - Each
itexample runs against a fresh instance of its example group, giving true test isolation as long as you avoid sharing mutable state viabefore(:all). expect(actual).to matcheronly fails when the matcher explicitly says so — a bare boolean expression with noexpectasserts nothing at all.letandsubjectprovide lazy, memoized helpers scoped to a single example; prefer them over ad hoc instance variables.eqchecks value equality (==), whileequalchecks object identity (equal?) — picking the wrong one is a common source of confusing failures.double,allow, andhave_receivedlet you isolate the code under test from slow or unpredictable real-world collaborators.- Real projects run specs with
bundle exec rspecagainst aspec/directory and a sharedspec_helper.rb, rather than executing files directly withruby.
