Skip to content
End To End Tester

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

ruby
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
end

describe / 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.

ruby
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
end

This 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

ruby
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_refundable

change { }.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

ruby
# 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))
end

instance_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:

ruby
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
end

That pattern is the answer to the fake-drift problem discussed in unit testing without mocks.

#Configuration worth having

ruby
# 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 }
end

order = :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 ↗
  • Rubyruby/spec/tools/rspec

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?