LaunchKit
← All posts
· 12 min read · by The LaunchKit team · 0 views

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.

#rails #testing

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.