Testing with Minitest and RSpec

Minitest assertions, RSpec describe and it blocks, let and subject, doubles and mocks, and organising a suite that stays fast.

From assertions to examples

# Minitest
require "minitest/autorun"

class PricingTest < Minitest::Test
  def setup
    @pricing = Pricing.new(discount: 0.15)
  end

  def test_applies_volume_discount
    assert_equal 85_000, @pricing.total(cents: 100_000, quantity: 50)
  end

  def test_rejects_negative_quantity
    assert_raises(ArgumentError) { @pricing.total(cents: 100, quantity: -1) }
  end

  def test_returns_nil_for_unknown_sku
    assert_nil @pricing.price_for("nope")
  end
end
# RSpec
RSpec.describe Pricing do
  subject(:pricing) { described_class.new(discount: 0.15) }

  let(:order) { build(:order, quantity: 50) }

  describe "#total" do
    it "applies the volume discount" do
      expect(pricing.total(cents: 100_000, quantity: 50)).to eq(85_000)
    end

    it "raises for a negative quantity" do
      expect { pricing.total(cents: 100, quantity: -1) }.to raise_error(ArgumentError)
    end
  end
end
  • let is lazy and memoised per example; let! evaluates eagerly in a before hook. Neither leaks between examples.
  • subject is just let(:subject) with a nicer name, and can be referred to implicitly in a one-liner it { is_expected.to be_valid }.
  • Prefer build over create when persistence is not part of the behaviour under test - it is often ten times faster.
💡
A test that asserts the implementation (which method was called, in what order) breaks on every refactor while catching nothing. Assert the observable result, and reserve mocks for things you cannot call for real - a payment provider, a clock, an external API.

Doubles, mocks and stubs

RSpec.describe InvoiceSender do
  let(:mailer)  { instance_double(Mailer, deliver: true) }
  let(:clock)   { class_double(Time, now: Time.utc(2026, 1, 1)) }

  subject(:sender) { described_class.new(mailer: mailer, clock: clock) }

  it "sends an invoice with the current date" do
    invoice = sender.call(order_id: 1)

    expect(mailer).to have_received(:deliver).with(hash_including(date: "2026-01-01"))
    expect(invoice.sent_at).to eq(Time.utc(2026, 1, 1))
  end

  it "does not send twice" do
    sender.call(order_id: 1)
    sender.call(order_id: 1)
    expect(mailer).to have_received(:deliver).once
  end
end
DoublesChecksUse when
doubleNothingA throwaway collaborator
instance_doubleMethod exists on the real classAlmost always - catches typos
class_doubleClass-level methods existReplacing a class method
spyRecords calls, allows anythingAssert after the fact
verify_partial_doublesConfig flagOn by default in modern RSpec setup
# controlling time without a dependency
travel_to(Time.utc(2026, 6, 1)) do
  expect(subscription.renews_on).to eq(Date.new(2026, 7, 1))
end

# freezing randomness so a failure is reproducible
srand(1234)
expect(generator.pick).to eq("gamma")

Keeping the suite useful

  • One behaviour per example, described in the present tense: it "rejects a blank title", not it "tests validation".
  • Use described_class and subject so renaming a class does not require editing every spec file.
  • Share setup with shared_context and shared_examples, but stop before the abstraction hides the scenario a failing test describes.
  • Run --format documentation occasionally and read the output as a specification; if it is unreadable, the test names are wrong.
  • --profile and --seed reproduce slow and order-dependent failures. Fix order dependence rather than disabling randomisation.
bundle exec rspec --format progress --profile 10
bundle exec rspec spec/models/book_spec.rb:42   # run one example
bundle exec rake test                            # minitest through Rake
COVERAGE=1 bundle exec rspec                     # with simplecov enabled

FAQ

Minitest or RSpec?
RSpec for expressive naming, rich matchers and the wider ecosystem. Minitest when you want a small dependency, plain Ruby classes and fast startup. Both are fine; consistency within a project matters more than the choice.
How many mocks is too many?
If a test needs more mocks than assertions, the class under test probably has too many collaborators. That is useful design feedback: extract a collaborator, or test through a smaller seam.

Gems, Bundler and project layout Rails essentials: MVC, ActiveRecord and routing

Last refreshed 2026-09-18.