Ruby Unit Testing: RSpec vs Minitest Compared
Ruby developers have two excellent unit testing frameworks to choose from: RSpec and Minitest. Both ship with strong matchers, mock support, and Rails integration. The choice is mostly about style preference, but there are real differences worth understanding.
Overview
RSpec is a Behavior-Driven Development (BDD) framework. It emphasizes human-readable test descriptions and a DSL that reads like English. RSpec is the most popular choice for Ruby/Rails testing by a wide margin.
Minitest ships with Ruby's standard library. It's lean, fast, and requires no configuration. Rails uses Minitest by default for newly generated projects.
Both frameworks are mature, actively maintained, and capable. The "best" choice depends on your team and project context.
Syntax Comparison
Basic Test
RSpec:
RSpec.describe Calculator do
describe '#add' do
it 'returns the sum of two numbers' do
calc = Calculator.new
expect(calc.add(2, 3)).to eq(5)
end
end
endMinitest:
require 'minitest/autorun'
class CalculatorTest < Minitest::Test
def test_add_returns_sum_of_two_numbers
calc = Calculator.new
assert_equal 5, calc.add(2, 3)
end
endRSpec tests read more like documentation. Minitest tests look like standard Ruby classes — straightforward and familiar to anyone who knows Ruby.
Context and Grouping
RSpec:
RSpec.describe User do
context 'when the user is an admin' do
let(:user) { build(:user, role: 'admin') }
it 'can manage other users' do
expect(user).to be_can_manage_users
end
end
context 'when the user is a member' do
let(:user) { build(:user, role: 'member') }
it 'cannot manage other users' do
expect(user).not_to be_can_manage_users
end
end
endMinitest:
class UserTest < Minitest::Test
def setup
@user = User.new
end
def test_admin_can_manage_users
@user.role = 'admin'
assert @user.can_manage_users?
end
def test_member_cannot_manage_users
@user.role = 'member'
refute @user.can_manage_users?
end
endMinitest doesn't have nested context blocks. Group by test class or use nested classes.
Assertions and Matchers
RSpec Matchers
RSpec provides an expressive matcher library:
expect(value).to eq(5)
expect(value).to be > 3
expect(value).to be_nil
expect(value).to be_truthy
expect(array).to include(3)
expect(array).to contain_exactly(1, 2, 3)
expect(string).to match(/pattern/)
expect(string).to start_with('prefix')
expect { code }.to raise_error(RuntimeError)
expect { code }.to change(User, :count).by(1)
expect(object).to have_attributes(name: 'Alice')
expect(object).to respond_to(:save)Custom matchers are easy to write:
RSpec::Matchers.define :be_a_valid_email do
match { |email| email =~ /\A[^@]+@[^@]+\z/ }
failure_message { |email| "#{email} is not a valid email address" }
end
expect(user.email).to be_a_valid_emailMinitest Assertions
Minitest has a comprehensive assertion set:
assert_equal expected, actual
assert_nil value
assert value
refute value
assert_includes array, element
assert_match pattern, string
assert_raises(RuntimeError) { code }
assert_difference 'User.count', 1 do
create_user
end
assert_respond_to object, :save
assert_instance_of String, valueMinitest::Spec adds RSpec-like expect syntax:
require 'minitest/spec'
describe User do
it 'has a name' do
user = User.new(name: 'Alice')
_(user.name).must_equal 'Alice'
end
endMocking and Stubbing
RSpec Mocks
RSpec includes a full mocking library:
# Stub a method
allow(user).to receive(:name).and_return('Alice')
# Verify a method was called
expect(mailer).to receive(:deliver_now).once
# Mock return value based on arguments
allow(service).to receive(:find).with(1).and_return(record)
allow(service).to receive(:find).with(99).and_raise(NotFound)
# Spy (call first, assert later)
spy = instance_spy(PaymentGateway)
allow(spy).to receive(:charge).and_return(true)Minitest Mocks
Minitest includes Minitest::Mock:
mock = Minitest::Mock.new
mock.expect :deliver_now, true
mailer = mock
# In your test...
service.call(mailer)
mock.verify # asserts all expected calls happenedFor stubbing, Minitest uses stub:
user.stub :name, 'Alice' do
assert_equal 'Alice', user.name
endOr use mocha gem for a richer API:
user.expects(:name).returns('Alice').onceRSpec's mock library is more ergonomic and feature-rich. Minitest's built-in mock is simpler but sufficient for most cases.
Setup and Teardown
RSpec:
RSpec.describe OrderService do
let(:user) { create(:user) }
let(:order) { create(:order, user: user) }
before(:each) do
allow(PaymentGateway).to receive(:charge).and_return(true)
end
after(:each) do
# cleanup if needed
end
endMinitest:
class OrderServiceTest < Minitest::Test
def setup
@user = create(:user)
@order = create(:order, user: @user)
PaymentGateway.stub(:charge, true) { }
end
def teardown
# cleanup if needed
end
endRSpec's let is lazy (runs only when referenced) and automatically re-created per example. Minitest's setup always runs.
Test Speed
Minitest is generally faster than RSpec for two reasons:
- Less overhead — Minitest loads less code and has a smaller runtime
- No DSL parsing — RSpec's readable DSL comes with a small parsing cost
In practice, the difference matters for large test suites (thousands of tests). For most projects, both are fast enough.
Parallel test execution is available in both:
- RSpec:
parallel_testsgem - Minitest:
minitest-parallelor Railsparallel tests (--parallelflag in Rails 6+)
Rails Integration
Minitest (Rails default):
New Rails apps come with Minitest configured. Test directories follow the structure:
test/
models/
controllers/
integration/
system/
helpers/
test_helper.rbGenerate tests automatically:
rails generate model User # creates test/models/user_test.rb
rails generate controller <span class="hljs-comment"># creates test/controllers/Run:
rails test
rails <span class="hljs-built_in">test <span class="hljs-built_in">test/models/user_test.rbRSpec (most popular):
Add rspec-rails, run rails generate rspec:install. Test directories:
spec/
models/
requests/
system/
spec_helper.rb
rails_helper.rbRun:
bundle exec rspec
bundle <span class="hljs-built_in">exec rspec spec/models/When to Choose RSpec
- Your team values readable, documentation-style test output
- You want a rich matcher library out of the box
- You're working on a large codebase where shared examples and custom matchers pay off
- The rest of your team (or company) is already using RSpec
When to Choose Minitest
- You want zero extra dependencies (Minitest is in stdlib)
- You're starting a new Rails project and want the default setup
- You prefer standard Ruby class-based tests over DSL
- Speed is critical and you have a very large test suite
- You're onboarding junior developers unfamiliar with RSpec's DSL
Mixing Both
It's possible to run both frameworks in one project, but it's messy and uncommon. Pick one and commit to it.
If you're adding RSpec to an existing Rails project with Minitest tests, you can run both with different commands — but long-term, migrate everything to one framework.
The Verdict
For new Rails projects today, RSpec is the more practical choice if you're on a team with existing RSpec experience. The ecosystem (rspec-rails, factory_bot, shoulda-matchers) is large and well-integrated.
Minitest is the right choice if you want simplicity, zero configuration, and the smallest possible dependency footprint.
Both will serve you well. The best test is the one your team will actually write and maintain.