Testing with RSpec

describe/context/it, expectations and matchers, before hooks, and a complete RSpec example.

Why RSpec

RSpec is the dominant testing framework in the Ruby ecosystem (Ruby also ships a lighter built-in Test::Unit/Minitest, but real-world Ruby and Rails projects overwhelmingly reach for RSpec). Its defining idea is behavior-driven development (BDD): tests are written as human-readable descriptions of behavior — "describe this class, when this happens, it should do that" — rather than bare assertions with no surrounding narrative.

Bash
gem install rspec
# or, in a Bundler-managed project:
bundle add rspec --group test
rspec --init   # scaffolds spec/spec_helper.rb and .rspec

describe, context, and it

  • describe groups examples around a class, method, or general area of behavior.
  • context groups examples around a particular situation or precondition — functionally identical to describe, but the naming convention communicates "under this condition" rather than "this subject."
  • it defines one individual example (a single test case), with a description of the expected behavior.
Ruby
# lib/calculator.rb
class Calculator
  def add(a, b)
    a + b
  end

  def divide(a, b)
    raise ZeroDivisionError, "cannot divide by zero" if b.zero?
    a / b.to_f
  end
end
Ruby
# spec/calculator_spec.rb
require_relative '../lib/calculator'

RSpec.describe Calculator do
  subject(:calculator) { described_class.new }

  describe '#add' do
    it 'returns the sum of two numbers' do
      expect(calculator.add(2, 3)).to eq(5)
    end
  end

  describe '#divide' do
    context 'when the divisor is not zero' do
      it 'returns the quotient as a float' do
        expect(calculator.divide(10, 4)).to eq(2.5)
      end
    end

    context 'when the divisor is zero' do
      it 'raises a ZeroDivisionError' do
        expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError, "cannot divide by zero")
      end
    end
  end
end
Bash
bundle exec rspec spec/calculator_spec.rb --format documentation
Plaintext
Calculator
  #add
    returns the sum of two numbers
  #divide
    when the divisor is not zero
      returns the quotient as a float
    when the divisor is zero
      raises a ZeroDivisionError

Finished in 0.01 seconds
4 examples, 0 failures

described_class refers back to whatever was passed to the outermost RSpec.describe (here, Calculator) — useful because it means the class name doesn't need to be repeated (and kept in sync) inside every nested subject.

Expectations: expect().to

expect(actual).to matcher is RSpec's core assertion syntax; expect(actual).not_to matcher is its negation. Notice the divide-by-zero example above wraps the call in a block (expect { ... }.to raise_error(...)) rather than calling it directly — that's required specifically for raise_error, since RSpec needs to invoke the code itself in order to catch the exception it raises.

Matcher Checks
eq(x) Value equality (==)
eql(x) Value equality and the same type (stricter than eq)
be(x) Object identity (equal?) — the exact same object, not just an equal one
raise_error(SomeError, "message") The block raises a matching exception (message argument optional)
include(x) A collection includes a given element, or a hash includes a given key/value
be_truthy / be_falsey A truthy/falsy value — not necessarily exactly true/false
be_nil The value is exactly nil

before and after hooks

before runs before every example in its scope (or once per file with before(:all), though before(:each) — the default — is by far the more common choice, since it guarantees a fresh, un-shared state per test):

Ruby
RSpec.describe Calculator do
  before(:each) do
    @calculator = Calculator.new
  end

  it 'adds two numbers' do
    expect(@calculator.add(1, 1)).to eq(2)
  end

  it 'adds negative numbers' do
    expect(@calculator.add(-1, -1)).to eq(-2)
  end
end

let is RSpec's more idiomatic alternative to instance variables set in before — it defines a memoized helper method, computed lazily the first time it's called within an example, and re-computed fresh for every example:

Ruby
RSpec.describe Calculator do
  let(:calculator) { Calculator.new }

  it 'adds two numbers' do
    expect(calculator.add(1, 1)).to eq(2)
  end
end

A brief, honest note on doubles and mocks

RSpec can also create doubles — fake objects standing in for a real dependency, so a test doesn't need a real database, network call, or other slow/external collaborator:

Ruby
RSpec.describe 'a notifier that depends on a mailer' do
  it 'calls deliver on the mailer' do
    mailer = double('Mailer')
    expect(mailer).to receive(:deliver).with('hello@example.com')

    mailer.deliver('hello@example.com')
  end
end

Doubles are genuinely useful for isolating a unit from slow or external collaborators, but they come with a real cost: a test built entirely around expect(...).to receive(...) verifies that specific methods were called, not that the code actually produces the right result — over-relying on them tends to produce tests that pass even after a refactor breaks real behavior, as long as the same methods still get called in the same order.

Common mistakes

  • Using eq when object identity (be) is actually what's being tested, or vice versa — eq(x) passes for two different objects with equal content; be(x) only passes for the exact same object.
  • Writing expect(calculator.divide(10, 0)).to raise_error(...) instead of expect { calculator.divide(10, 0) }.to raise_error(...) — without the block, Ruby evaluates calculator.divide(10, 0) immediately, the exception is raised right there before RSpec ever gets to check anything, and the spec errors out ungracefully instead of failing cleanly with a normal RSpec failure message.
  • Packing multiple, unrelated assertions into a single it block — when it fails, it's unclear which of several things actually broke; prefer one clear behavior per example.
  • Over-relying on mocks/doubles to the point that tests verify how code calls its collaborators rather than what it actually produces, making tests brittle to harmless refactors and blind to real behavioral regressions.

Interview questions

Q: What's the difference between describe and context in RSpec? They're functionally identical — both group related examples — but convention uses describe for the subject under test (a class or method) and context for a particular situation or precondition ("when the user is logged in", "when the divisor is zero"), which makes the resulting output read more like a specification document.

Q: How do you test that a method raises a specific exception in RSpec? Wrap the call in a block and use raise_error: expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError, "cannot divide by zero"). The block form is required because RSpec needs to invoke the code itself inside a begin/rescue internally to catch the exception — calling the method directly outside the block would raise before RSpec could intercept it.

Q: What's the purpose of a before(:each) hook, and how does let differ from it? before(:each) runs a block before every example in its scope, typically to set up fresh state (like a new instance) so tests don't leak state into each other. let(:name) { ... } achieves a similar goal but defines a lazily-evaluated, memoized helper method instead of an instance variable — it's only computed the first time it's referenced in a given example, and freshly recomputed for the next one.