Writing RSpec-Style Tests

Published by Programming Line in Ruby. Last updated .

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:

  1. The file is loaded (via require 'rspec/autorun' for a standalone script, or via the rspec command line tool for a project), and every describe/context block executes immediately — but only to register nested groups, it examples, and hooks. No test logic runs yet.
  2. Once everything is loaded, the runner asks each example group, in turn, to run its examples.
  3. For each it block, RSpec instantiates a fresh instance of that group. This is why let, subject, and instance variables set in before are private to that one example.
  4. Any before hooks (outer groups first, then inner) run against that fresh instance.
  5. The example’s own block runs. Every expect(...).to matcher call inside it either passes silently or raises a failure that RSpec catches and records — it does not stop the whole suite.
  6. Any after hooks run (inner groups first, then outer), even if the example failed.
  7. Once every example has run, the formatter prints the summary: a dot per pass, an F per 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 it focused on one behavior; a descriptive title (“raises an error when the balance is negative”) should read like a sentence.
  • Prefer let and subject over instance variables set in before — they’re lazy (only computed if used), which keeps unrelated examples fast and avoids stale setup.
  • Default to before(:each) (just before) for anything mutable; reserve before(:all) for genuinely expensive, read-only setup, and never mutate what it creates.
  • Use context to describe a state or condition (“when the cart is empty”, “when the user is an admin”) and describe for the class or method under test — the distinction is convention, not a language rule, but keeping it consistent makes specs easier to scan.
  • Use double and allow/expect(...).to have_received to isolate the unit under test from slow or unreliable collaborators (databases, HTTP calls, mail servers).
  • Organize real projects with a spec/ directory, one *_spec.rb file per class, and a shared spec_helper.rb loaded from a .rspec file — install with gem install rspec or add gem "rspec" to your Gemfile, then run bundle exec rspec --init once to scaffold it.
  • Run the whole suite with bundle exec rspec (or plain rspec if installed globally) rather than ruby on individual files, so hooks, shared configuration, and custom matchers load correctly.

Practice Exercises

  • Write a FizzBuzz class with a call(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 nested context blocks for each of the four cases, with at least one it per context.
  • Write a Temperature class with a celsius_to_fahrenheit(c) method. Write a spec using subject and let. Then deliberately introduce a before(:all) block that mutates an instance variable across two examples, run it mentally, and predict which example fails and why — then rewrite it with before(:each) to fix it.
  • Write a PaymentProcessor class whose charge(amount) method delegates to an injected gateway object’s charge(amount) method. Write a spec using double and allow(...).to receive to verify that gateway.charge is called with the correct amount, without implementing a real gateway.

Summary

  • RSpec is a Ruby gem, not a language feature — describe, it, context, and expect are ordinary methods that accept blocks, built on Ruby’s DSL-friendly syntax.
  • Each it example runs against a fresh instance of its example group, giving true test isolation as long as you avoid sharing mutable state via before(:all).
  • expect(actual).to matcher only fails when the matcher explicitly says so — a bare boolean expression with no expect asserts nothing at all.
  • let and subject provide lazy, memoized helpers scoped to a single example; prefer them over ad hoc instance variables.
  • eq checks value equality (==), while equal checks object identity (equal?) — picking the wrong one is a common source of confusing failures.
  • double, allow, and have_received let you isolate the code under test from slow or unpredictable real-world collaborators.
  • Real projects run specs with bundle exec rspec against a spec/ directory and a shared spec_helper.rb, rather than executing files directly with ruby.