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
endletis lazy and memoised per example;let!evaluates eagerly in abeforehook. Neither leaks between examples.subjectis justlet(:subject)with a nicer name, and can be referred to implicitly in a one-linerit { is_expected.to be_valid }.- Prefer
buildovercreatewhen 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| Doubles | Checks | Use when |
|---|---|---|
double | Nothing | A throwaway collaborator |
instance_double | Method exists on the real class | Almost always - catches typos |
class_double | Class-level methods exist | Replacing a class method |
spy | Records calls, allows anything | Assert after the fact |
verify_partial_doubles | Config flag | On 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", notit "tests validation". - Use
described_classandsubjectso renaming a class does not require editing every spec file. - Share setup with
shared_contextandshared_examples, but stop before the abstraction hides the scenario a failing test describes. - Run
--format documentationoccasionally and read the output as a specification; if it is unreadable, the test names are wrong. --profileand--seedreproduce 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 enabledFAQ
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.
Related
Gems, Bundler and project layout Rails essentials: MVC, ActiveRecord and routing
Last refreshed 2026-09-18.