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.
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
describegroups examples around a class, method, or general area of behavior.contextgroups examples around a particular situation or precondition — functionally identical todescribe, but the naming convention communicates "under this condition" rather than "this subject."itdefines one individual example (a single test case), with a description of the expected behavior.
# 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
# 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
bundle exec rspec spec/calculator_spec.rb --format documentation
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):
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:
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:
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
eqwhen 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 ofexpect { calculator.divide(10, 0) }.to raise_error(...)— without the block, Ruby evaluatescalculator.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
itblock — 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.