RSpec vs Minitest in Rails
Rails ships one test framework and a lot of Rails teams install the other one first thing. That is the whole shape of the rails rspec vs minitest question, and most of the arguments people bring to it are about how a test reads, which is the part where there is least to decide.
Everything below was run on this machine: Ruby 4.0.5 on arm64-darwin25 with 12 cores, PostgreSQL 17.7, a fresh Rails 8.1.4 application for the benchmarks, and LaunchKit itself on Rails 8.1.3.1 for the numbers about a real suite. Gem versions are rspec-rails 8.0.4, rspec-core 3.13.6, minitest 6.0.6, parallel_tests 5.8.0, shoulda-matchers 8.0.1.
What a Rails application already has
Minitest is not an option a Rails app opts into. The activesupport gemspec lists it as a runtime
dependency:
$ gem specification activesupport -v 8.1.3.1 | ruby -ryaml -e '...'
minitest >= 5.1 (runtime)
Which is why LaunchKit's Gemfile.lock carries minitest (6.0.6) at line 265 and
minitest (>= 5.1) under activesupport at line 82, while grep minitest Gemfile returns
nothing and the suite is RSpec top to bottom. Minitest is in the production bundle of every Rails
application on earth.
What rails new writes on top of that is a test/ tree, ten test_unit generators
(authentication, controller, generator, helper, integration, job, mailer, model, scaffold, system),
and this test/test_helper.rb:
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
end
end
Two lines of configuration, and both of them do real work. fixtures :all is the choice argued
out in Rails fixtures vs factories. parallelize is the one
that decides the speed section of this post, and it has no equivalent in rspec-core.
What rspec-rails adds on top
rspec-rails 8.0.4 was released on 2026-03-11 and rspec-core is at 3.13.6. RSpec 4 exists as 4.0.0.beta1, published 2026-02-18, and the entire changelog entry for rspec-rails 8.0.4 reads "Released to relax version constraint for rspec to allow 4.0.0.beta1", so the major version is in flight but not landed.
Adding the gem changes two things nobody tells you about. The first is generators. In
lib/rspec-rails.rb, lines 31 to 33:
generators = config.app_generators
generators.integration_tool :rspec
generators.test_framework :rspec
No configuration, no flag. Run bin/rails g model Widget name:string after a bundle install and
you get spec/models/widget_spec.rb and no fixture file. rspec-rails ships fourteen generators
against Rails' ten, and the five Rails has no counterpart for are channel, feature, mailbox,
request and view. Rails' integration is what RSpec calls request.
The second is the rake default, in rspec/rails/tasks/rspec.rake lines 2 to 6:
if default = Rake.application.instance_variable_get('@tasks')['default']
default.prerequisites.delete('test')
end
task default: :spec
So rake now runs the specs and no longer runs the tests, while bin/rails test carries on
running test/ exactly as before. That is worth knowing because it is also the migration path: a
Rails app can hold both suites at once, green, indefinitely. The benchmark application below did
exactly that for the whole session, one test/ tree and one spec/ tree booting the same
config/environment, and neither runner ever saw the other's files.
The syntax argument, and the one part of it that is not taste
One assertion, written twice, against a model carrying validates :title, presence: true:
# test/models/fail_test.rb
test "predicate" do
a = Article.new(title: nil)
assert a.valid?
end
# spec/models/fail_spec.rb
it "predicate" do
a = Article.new(title: nil)
expect(a).to be_valid
end
assert_equal expected, actual against expect(actual).to eq(expected) is taste. Argument order,
let against an instance variable, describe against a class name: taste, and anyone who tells
you their side is objectively more readable is describing which one they read first. Drop it.
What is not taste is what each one prints when it breaks. Both of the above were run:
# Minitest
Failure:
FailTest#test_predicate [test/models/fail_test.rb:12]:
Expected false to be truthy.
# RSpec
Failure/Error: expect(a).to be_valid
expected #<Article id: nil, title: nil, body: nil, published: nil, created_at: nil,
updated_at: nil> to be valid, but got errors: Title can't be blank
"Expected false to be truthy" is the worst sentence in Rails testing. Reaching for
assert_predicate a, :valid? improves it to "Expected #<Article id: nil, ...> to be valid?.",
which names the object and still will not tell you which validation fired. The RSpec side is not
cleverness, it is a thirty-line matcher class in
rspec-rails-8.0.4/lib/rspec/rails/matchers/be_valid.rb, where failure_message appends
", but got errors: #{errors.map(&:to_s).join(', ')}" when the subject responds to errors.
Nothing stops Minitest from having that. It does not have it.
Sharing one contract across many test classes
LaunchKit has nine shared example files and 53 it_behaves_like call sites, the busiest being
"a basic-auth protected page" at fourteen. One of them:
RSpec.shared_examples "a feature-flagged route" do
context "when the feature is disabled" do
before do
Setting.current.update!(features: { feature.to_s => false })
Setting.reset_cache!
end
it "responds with 404" do
get path
expect(response).to have_http_status(:not_found)
end
end
end
The common claim is that Minitest cannot do this. Minitest can do this, with
ActiveSupport::Concern, the same mechanism described in
Rails concerns:
module ARecordWithATitle
extend ActiveSupport::Concern
included do
test "rejects a blank title" do
assert_not build_subject(title: nil).valid?
end
test "rejects a title over 120 characters" do
assert_not build_subject(title: "x" * 121).valid?
end
end
end
class ArticleTitleTest < ActiveSupport::TestCase
include ARecordWithATitle
def build_subject(**attrs) = Article.new(body: "b", **attrs)
end
That ran: 2 runs, 2 assertions, 0 failures. The parameter arrives through a method instead of
through let, and the module has to be on the load path, which the generated test_helper.rb does
not arrange for you. Call it three extra lines. This is a wash and it should stop being cited.
Where Minitest genuinely has less is nesting. RSpec composes context blocks with their own
before hooks; Minitest gives you one setup per class, and a second set of preconditions means a
subclass. On a request spec with four states of the same endpoint, that is four classes.
Where the ecosystem difference is real
The folklore is "RSpec has more gems". Most of the ones people name work in both. factory_bot
integrates into ActiveSupport::TestCase as readily as into RSpec, which
Rails fixtures vs factories goes into. WebMock, VCR and
ActiveSupport::Testing::TimeHelpers are framework-neutral.
shoulda-matchers is where it stops being folklore. Version 8.0.1 states Minitest compatibility in
its first sentence and its README gives Minitest users this exact block for test/test_helper.rb:
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :minitest
with.library :rails
end
end
Installed, configured that way, a Rails 8.1 Minitest test has no assertion to feed a matcher to:
NoMethodError: undefined method 'assert_accepts' for an instance of ShouldaTest
And calling the matcher directly does not work either, because validate_presence_of(:title)
returns a Shoulda::Matchers::MatcherCollection with no subject:
NoMethodError: undefined method 'attribute_setter' for nil. The should macro lives in
shoulda-context, pulled in by the shoulda umbrella gem, which the README also tells you to add.
Adding gem "shoulda", "~> 4.0" resolves, and here is what it resolves to:
$ bundle install
Fetching shoulda-matchers 4.5.1 (was 8.0.1)
Fetching shoulda-context 2.0.0
Fetching shoulda 4.0.0
A four-major-version downgrade, because shoulda 4.0.0 depends on shoulda-matchers ~> 4.0. It then
works: should validate_presence_of(:title) passed. On a 2020 gem stack. shoulda 4.0.0 and
shoulda-context 2.0.0 were both released on 2020-06-13; the only newer things on either line are
release candidates, 5.0.0.rc1 from 2023 and 3.0.0.rc1 from 2024.
Beyond that the RSpec-only list is short and specific: rswag 2.17.0, released 2025-11-05, whose README opens with "Seeking maintainers!"; rspec-openapi 0.34.0, released 2026-09-22, which is alive but whose Minitest support its own README calls experimental and short of features; and rubocop-rspec 3.10.2, which has no Minitest equivalent of that size. That is the real ecosystem difference. It is narrower than the argument usually is, and it is sharper.
Speed, measured on identical work
The benchmark is 120 examples, each inserting 100 rows and running one COUNT, written twice from
one generator script so the two files do the same thing. Wall clock, /usr/bin/time -p, best of
four runs after warm-up:
| wall | |
|---|---|
rspec, one process |
4.58 s |
bin/rails test, PARALLEL_WORKERS=1 |
4.78 s |
bin/rails test, 12 forks |
1.50 s |
parallel_rspec, 12 processes |
3.32 s |
Boot cost, measured separately on a file holding one trivial assertion: 0.53 s for
bin/rails test, 0.64 s for rspec. On 200 assertion-only examples with no database, single
process, RSpec reported Finished in 0.11533 seconds and Minitest Finished in 0.133297s.
Serially, the two frameworks are the same speed, and on this workload RSpec was marginally the faster of the two. The 20 to 30 percent that gets repeated as the payoff for switching is measuring something else, most often a suite that also changed its fixtures strategy on the way across. The gap in the table is entirely in the two parallel rows.
Why the parallel runner is the whole gap
Rails' runner and parallel_tests are not two implementations of one idea. In
activesupport-8.1.4/lib/active_support/testing/parallelization/worker.rb:14, the worker calls
fork, and jobs arrive one test method at a time over a DRb queue. Rails is loaded once, in the
parent, before any worker exists, and the distribution unit is a single test.
parallel_tests takes the other route. parallel_tests-5.8.0/lib/parallel_tests/test/runner.rb:107
runs IO.popen(env, cmd, popen_options): twelve fresh rspec invocations, each booting the
application from scratch, each handed a set of whole files. So the boot cost is paid twelve times
instead of once, and the slowest file sets the floor rather than the slowest example.
At 0.5 s of boot and twelve workers that is six seconds of CPU spent before a single example runs, which is why the parallel_rspec row is 3.32 s against Minitest's 1.50 s on work that takes 4.6 s serially. The gem is maintained, 5.8.0 landed 2026-09-13, and it is the right tool if you are on RSpec. It is structurally behind, and no amount of maintenance closes a gap that is about where the process boundary sits.
What the generated parallelize costs a fast suite
Running 200 assertion-only examples through the test_helper.rb that rails new writes, unedited:
# PARALLEL_WORKERS=1
Finished in 0.133297s, 1500.4089 runs/s
real 0.68
# the generated default, 12 forks
Finished in 0.319067s, 626.8276 runs/s
real 0.92
The default made it 2.4 times slower. Forking twelve processes and wiring twelve DRb clients costs
more than 0.13 seconds of assertions, and there is no way for the runner to know that in advance.
Rails guards this with a floor: parallelize takes
threshold: ActiveSupport.test_parallelization_threshold, documented at
activesupport-8.1.4/lib/active_support/test_case.rb:103 as defaulting to 50 and settable through
config.active_support.test_parallelization_threshold. Fifty is a count of tests, not a measure of
work, and 200 fast ones sail past it.
Forking is therefore not a free win, it is a win above a workload the threshold does not measure. Minitest's parallel runner is worth a lot on a suite that touches a database, and on a suite of quick unit assertions the same generated default is a tax you have to know to turn off. The fix is to raise the threshold or drop the workers, both one line, and nothing in a green build will ever tell you to.
What 846 RSpec examples bought this codebase, and what they cost
LaunchKit runs RSpec: 141 spec files, 8001 lines, 846 examples, 15.49 seconds with 1.31 seconds of
file loading, zero failures. AGENTS.md:63 states the policy in one line: "RSpec. Add a spec with
every change. For a bug, write the failing spec first, then fix it."
What the choice bought is legible in the counts. 200 let( declarations, 61 subject
declarations, and the nine shared example files already mentioned, which is the piece worth paying
for: it_behaves_like "a basic-auth protected page" appears fourteen times, so fourteen admin
routes are held to one authentication contract written once, and adding the fifteenth route is a
single line. The Minitest concern above does the same job, but nothing would have pushed anyone
toward writing it, whereas RSpec.shared_examples is the obvious move the moment the second route
appears.
What it cost is two things. The suite runs in one process, and --profile 10 says
Top 10 slowest examples (12.04 seconds, 70.6% of total time) with the worst single example at
3.69 seconds. By arithmetic rather than measurement, a fork-based runner cannot take this suite
below that 3.69 seconds, which is still roughly a quarter of the 15.49 it takes now, and no such
runner exists for rspec-core. The second cost is deeper: three of the four generators under
lib/generators/launchkit/ write RSpec. service_generator.rb has a create_spec method that
templates into spec/services/#{class_path}/#{file_name}_spec.rb. Switching this codebase to
Minitest is not 141 files, it is 141 files plus the generators customers run, plus the rubocop-rspec
cop set in .rubocop.yml. There is no tool for it either: minitest_to_rspec exists, goes the
wrong direction, and its last release is 0.13.0 from 2018-10-17.
The verdict
New Rails application, no existing suite, nobody on the team with a strong preference: use
Minitest. The framework is already in your bundle, the generated test_helper.rb hands you a
fork-based parallel runner that no RSpec setup can match, and the syntax argument that was supposed
to justify the extra gem turns out to be taste with one exception. Turn the parallelization
threshold up if your suite is fast, and read the failure messages before you commit to them.
Existing RSpec suite of any size: stay. The conversion cost is real, the tooling for it does not exist, and the runtime you would win back is available more cheaply by finding whatever your equivalent of that 3.69 second example is.
Two things would change the first half of that. If rspec-core grew a fork-based runner that boots
once and distributes examples, the last structural argument for Minitest would be gone and the
recommendation would flip to taste, which means RSpec wins on failure output. And if Rails ever
shipped assert_valid with the model's errors in the message, plus a documented way to share a
contract between test classes that does not require knowing about ActiveSupport::Concern, the
gap in the other direction would close too. Neither is on anybody's roadmap. The question is
settled by a fork call on line 14 of a file almost nobody has read.
Comments
No comments yet. Be the first.