RSpec
Ruby's specification-style framework — describe and context blocks, let and subject, matchers, and the readability trade it makes.
2 min read · updated 19 September 2026
RSpec reads like a specification. That is its whole design goal, and it produces test suites that are unusually pleasant to read and occasionally too clever to follow.
#The shape
RSpec.describe Basket do
subject(:basket) { described_class.new }
it "costs nothing when empty" do
expect(basket.total_cents).to eq(0)
end
context "with two books at £12.00" do
before { basket.add("book-1", quantity: 2, unit_cents: 1_200) }
it "totals the lines" do
expect(basket.total_cents).to eq(2_400)
end
it "counts the items" do
expect(basket.item_count).to eq(2)
end
end
context "when a discount code applies" do
let(:policy) { DiscountPolicy.percent_over("SUMMER", 10, threshold_cents: 2_000) }
it "applies it at the threshold" do
basket.add("book-1", quantity: 2, unit_cents: 1_000)
expect(basket.total_with(policy).discount_cents).to eq(200)
end
end
enddescribe / context / it produce a readable failure line:
Basket with two books at £12.00 totals the lines.
#let and subject
let defines a lazily evaluated, memoised helper. It is not run unless an
example references it, and it is recomputed for each example.
let(:customer) { Customer.new(tier: tier) }
let(:tier) { :standard } # overridden in nested contexts
context "for a gold customer" do
let(:tier) { :gold } # only this line changes
it "ships free" do
expect(shipping_for(customer).cents).to eq(0)
end
endThis override mechanism is RSpec's most distinctive idea, and its most
divisive. It removes enormous duplication; it also means the value of
customer in a given example may be determined four context blocks above
where you are reading. The discipline: keep the chain shallow. Two
levels of override is comfortable; four is a puzzle.
let! forces eager evaluation in a before hook — needed when the fixture
has a side effect the example depends on but does not reference.
#Matchers
expect(order.status).to eq("paid")
expect(order.lines).to contain_exactly(having_attributes(sku: "book-1"))
expect(order.total_cents).to be_within(1).of(2_795)
expect { service.reserve(order) }.to raise_error(InsufficientStock, /book-1/)
expect { service.place(order) }.to change { Order.count }.by(1)
expect(response).to have_http_status(:payment_required)
# Compound
expect(order).to have_attributes(status: "paid").and be_refundablechange { }.by(1) is a good example of the style: it states the effect
rather than the before-and-after values, which is both shorter and more
durable.
#Doubles
# A verifying double: fails if PaymentGateway has no `charge` method with
# this arity. Use these, not plain doubles — they cannot drift from reality.
let(:gateway) { instance_double(PaymentGateway, charge: ChargeResult.succeeded("pi_1")) }
it "charges once the order is reserved" do
checkout = Checkout.new(gateway: gateway)
checkout.pay(order)
expect(gateway).to have_received(:charge).with(hash_including(cents: 4_000))
endinstance_double over double is the single most valuable RSpec mocking
habit: a plain double will happily stub a method that does not exist,
producing a green test for code that cannot work. See
test doubles.
#Shared examples
The cleanest expression of contract testing in any framework here:
RSpec.shared_examples "an order repository" do
it "saves and reads back" do
repository.save(Order.new(reference: "REF-1", amount_cents: 1_999))
expect(repository.find("REF-1").amount_cents).to eq(1_999)
end
it "rejects a duplicate reference" do
repository.save(Order.new(reference: "REF-1"))
expect { repository.save(Order.new(reference: "REF-1")) }
.to raise_error(DuplicateReference)
end
end
RSpec.describe InMemoryOrderRepository do
let(:repository) { described_class.new }
it_behaves_like "an order repository"
end
RSpec.describe PostgresOrderRepository do
let(:repository) { described_class.new(connection) }
it_behaves_like "an order repository" # the fake cannot drift silently
endThat pattern is the answer to the fake-drift problem discussed in unit testing without mocks.
#Configuration worth having
# spec/spec_helper.rb
RSpec.configure do |config|
config.disable_monkey_patching! # `RSpec.describe`, not bare `describe`
config.order = :random # surfaces order dependencies
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = ".rspec_status" # --only-failures
config.expect_with(:rspec) { |c| c.max_formatted_output_length = 200 }
config.mock_with(:rspec) { |c| c.verify_partial_doubles = true }
endorder = :random and verify_partial_doubles = true are the two that
prevent real classes of bug — order dependence, and stubbing methods that do
not exist.
#The readability trade
RSpec suites can be beautiful and can be inscrutable. The failure mode is
deep context nesting with let overrides at every level, where
understanding one four-line example means reading eighty lines above it.
The counter-discipline is the same as everywhere: keep the setup close to the assertion, prefer explicit values over inherited ones, and remember that a test is read far more often than it is written.
Common questions
- Is RSpec better than Minitest?
- Different. RSpec optimises for expressive, readable specifications and has a much larger DSL; Minitest is plain Ruby with almost no DSL and is faster to run. Rails ships with Minitest and most of the Rails community uses RSpec anyway.
- Should I use let or instance variables?
- let, generally — it is lazy, memoised per example, and avoids the shared-state hazards of before blocks. The caveat is that heavy use of let pushes setup away from the example that depends on it, which is the main readability complaint about RSpec suites.
- What are shared examples for?
- Asserting that several implementations satisfy the same contract — an in-memory repository and a real one, for instance. They are the cleanest expression of contract-style testing in any framework covered here.
Runnable samples for this page
last test results ↗- Ruby
ruby/spec/tools/rspec
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Behaviour-Driven DevelopmentBDD as a conversation practice rather than a tool choice — what the three amigos session produces, when Cucumber earns its place, and how it fails.
- Given-When-ThenThe Gherkin vocabulary for describing behaviour, how it maps onto arrange-act-assert, and when a shared specification language is worth its cost.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.
- Naming and Structuring TestsTest names that say what broke without opening the file, the naming conventions worth adopting, and how to organise a suite so people can find things.