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

Rails testing tools, priced by what each one can see

A Rails application has more testing machinery in it than anybody uses, and the question people bring to it is which Ruby on Rails testing framework to install, which is the decision that matters least. What matters is what each tool can see, because a defect is only catchable by something that boots enough of the application to reach it, and most arguments about Rails testing tools are conducted without that number in front of anybody.

This page is the layer above three others. RSpec vs Minitest in Rails settles which DSL, Rails system tests prices the browser, and FactoryBot covers where test data comes from. None of them answers what to reach for when you have a bug to catch.

Everything below was run on this machine: Ruby 4.0.5 on arm64-darwin25 with 12 cores, PostgreSQL 17.7 on port 15432, two real Rails 8.1.3.1 applications, and a throwaway app generated today, which came out at Rails 8.1.4. Gem versions are rspec-rails 8.0.4, brakeman 8.0.6, bundler-audit 0.9.3, capybara 3.40.0, selenium-webdriver 4.49.0, prosopite 2.2.0, minitest 6.0.6.

Eight base classes, and the choice between them is a boot decision

Rails 8.1 defines eight test case classes, and one bin/rails runner loaded all of them here without a gem beyond the framework:

%w[ActiveSupport::TestCase ActiveJob::TestCase ActionMailer::TestCase ActionView::TestCase
   ActionDispatch::IntegrationTest ActionDispatch::SystemTestCase ActionCable::TestCase
   ActionMailbox::TestCase].each { |k| puts "#{k} #{Object.const_get(k) ? "OK" : ""}" }
ActiveSupport::TestCase OK
ActiveJob::TestCase OK
ActionMailer::TestCase OK
ActionView::TestCase OK
ActionDispatch::IntegrationTest OK
ActionDispatch::SystemTestCase OK
ActionCable::TestCase OK
ActionMailbox::TestCase OK

Picking one of those is not a stylistic choice, it is a decision about how much of the application starts up before your assertion runs. ActiveSupport::TestCase gives you a database connection and 60 assert_* methods and nothing else. ActionDispatch::IntegrationTest adds routing, the full middleware stack, the controller and the template. ActionDispatch::SystemTestCase adds Puma, chromedriver and a browser process.

If you write RSpec, you are still using those classes. rspec-rails lib/rspec/rails/example/request_example_group.rb:13 reads include ActionDispatch::IntegrationTest::Behavior. A request spec and a Rails integration test are the same object with two vocabularies in front of it, which is why "integration testing" means something narrower in Rails than it does in the industry generally: in Rails it means one process, one HTTP call, no browser.

The ladder, priced on a suite that exists

This site's own RSpec suite is 200 *_spec.rb files and 15,374 lines of them, and it runs single process on the machine described above: 1665 examples in 28.2 s. Split by directory:

Directory Examples Wall Per example
spec/lib 101 0.278 s 2.7 ms
spec/models 408 2.51 s 6.2 ms
spec/repository 22 0.221 s 10.0 ms
spec/services 178 3.86 s 21.7 ms
spec/requests 800 18.86 s 23.6 ms

Request specs are 48 percent of the examples and 67 percent of the clock. That ratio is the whole economics of a Rails suite, and it is the number to look at before anybody proposes a faster CI runner. The boilerplate product repository runs the same shape: 851 examples across 142 spec files in 19.51 s today, up from the 846 recorded two days ago.

The floor underneath every one of those numbers is the boot. bundle exec ruby -e 'require "./config/environment"' takes 0.81 s here. An rspec invocation with a filter that matches nothing takes 1.07 s of wall clock and runs zero examples. So spec/lib, all 101 examples of it, is free: the entire directory costs less than a third of a second against a 1.07 s fixed price you have already paid. The conclusion people draw from that is usually wrong. Moving logic into lib/ to get faster tests saves 3.5 ms per example and nothing else, because you still paid the second.

The rung above the table is the browser, at roughly 130 ms per test against 4.6 ms for the same assertions as integration tests. That measurement and the reasons for it are in Rails system tests and are not repeated here.

Four test layers drawn as a ladder of stacked bars, cheapest at the bottom, each bar naming what boots, what one example costs and the defect class only that layer can see. A plain Ruby object in spec/lib costs 2.7 ms and sees logic errors only. ActiveSupport::TestCase adds a PostgreSQL connection, a transaction and a rollback at 6.2 ms and sees business rules, validations and SQL. ActionDispatch::IntegrationTest adds routing, middleware, the controller and the template at 23.6 ms and is the only rung that sees authorisation holes and N plus one queries via Prosopite. ActionDispatch::SystemTestCase adds Puma, chromedriver and Chrome at 130 ms and sees Stimulus, Turbo and what is invisible to the server. Off to the right, a separate box for the static tools that boot nothing, Brakeman 8.0.6 with 79 checks in 1.88 s and 0 warnings and bundler-audit 0.9.3 over 1247 advisories in 0.39 s, with an arrow running from that box up into the integration rung labelled authorisation lands here, no scanner has a shape for it. Under the whole ladder a black plinth reads 1.07 s boot, paid once per rspec invocation.

"Unit testing" names a thing Rails does not have

Rails ships no unit test class. ActiveSupport::TestCase is the base of all seven of the others, and what a Rails team calls a unit test is a model test, which opens a PostgreSQL connection, wraps itself in a transaction, and rolls back. The 6.2 ms per model example above is mostly that.

The genuinely unit-shaped tests in this repository are the 101 in spec/lib, which test plain Ruby objects and touch no connection, at 2.7 ms. The difference is a factor of 2.3 and it is invisible next to the 1.07 s boot. Which is the useful version of the advice: separating pure logic from Active Record is worth doing for reasons of design, and the test speed argument for it does not survive being measured.

What only a request spec can see

N+1 queries are the clearest case of a defect that exists at exactly one layer. This site detects them with prosopite, wired in config/environments/test.rb:34:

require "prosopite/middleware/rack"
config.middleware.use(Prosopite::Middleware::Rack)
config.after_initialize { Prosopite.raise = true }

Rack middleware. It scans one HTTP request at a time, which means it sees a controller and a template and does not see your factory setup. A model spec cannot trigger it, a service spec cannot trigger it, and a system test triggers it in a different process. Driven by hand in a scratch example against the real models here, it produces:

RAISED Prosopite::NPlusOneQueriesError
N+1 queries detected (0.6ms):
  SELECT "leads".* FROM "leads" WHERE "leads"."id" = $1 LIMIT $2
  SELECT "leads".* FROM "leads" WHERE "leads"."id" = $1 LIMIT $2
  SELECT "leads".* FROM "leads" WHERE "leads"."id" = $1 LIMIT $2

Three identical selects and a raise. The comment above that middleware line in the repository says what the placement buys: "The middleware scans each HTTP request (not the factory setup), so an N+1 in a controller/view raises and fails the request spec." That is the argument for having more than one kind of test, and it is not the argument usually given: it is not about confidence levels or pyramids, it is that some instruments are only installed at one altitude.

One detail if you go looking: Prosopite.raise is a writer with no matching reader, because Prosopite.raise resolves to Kernel#raise. Reading the flag back needs Prosopite.instance_variable_get(:@raise), which returned true.

bin/ci, which is the framework's own answer

Rails 8.1 shipped a CI runner, so automated testing in a Rails application now has a default shape and a file to argue about. The railties changelog entry reads "Introduce bin/ci for running your tests, style checks, and security audits locally or in the cloud", credited to Jeremy Daer and DHH, and it generates two files. bin/ci is five lines. config/ci.rb is the list. Generated today by rails new:

CI.run do
  step "Setup", "bin/setup --skip-server"
  step "Style: Ruby", "bin/rubocop"
  step "Security: Gem audit", "bin/bundler-audit"
  step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
  step "Tests: Rails", "bin/rails test"
  step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
end

The Tests: Seeds step is the one worth stealing if you take nothing else from the file. db:seed:replant truncates and reseeds the test database, which turns db/seeds.rb into something the build breaks on. Seeds rot silently otherwise: nothing else in a Rails test run ever executes them.

The engine is ActiveSupport::ContinuousIntegration, 145 lines in activesupport 8.1.3.1. It sets ENV["CI"] = "true", times each step, prints a green tick or a red cross with the elapsed time, and aggregates. On the empty generated app the whole run is 7.85 s. On this site, whose config/ci.rb has been edited to add an importmap audit, bin/rails zeitwerk:check and bundle exec rspec in place of bin/rails test, it is 48.10 s:

✅ Setup passed in 5.85s
✅ Style: Ruby passed in 1.84s                      (558 files inspected, no offenses detected)
❌ Security: Gem audit failed in 0.39s
✅ Security: Importmap vulnerability audit passed in 1.06s
✅ Security: Brakeman code analysis passed in 3.62s
✅ Autoloading: Zeitwerk check passed in 1.16s
✅ Tests: Prepare database passed in 1.44s
❌ Tests: RSpec failed in 32.75s
❌ Continuous Integration failed in 48.10s

bin/ci keeps going after a step fails

ActiveSupport::ContinuousIntegration#step has a two-line body and the second line is report(title) { results << system(*command) }. system returns false on a non-zero exit, the false goes into an array, and the array is checked once at the end by success?. No set -e, no early return.

Proved rather than read: adding step "After the failure", "echo I still ran" immediately below a failing test step gave

❌ Tests: Rails failed in 1.87s
I still ran
✅ After the failure passed in 0.00s
✅ Tests: Seeds passed in 0.56s
❌ Continuous Integration failed in 7.02s

and echo $? printed 1.

The cost is real and you should decide about it rather than discover it. A failed Setup step means every later step runs against an application whose dependencies did not install, so one broken Gemfile produces seven red steps and you read all of them to find the first. What you buy is the other case, which is more common: a run where RuboCop and Brakeman and the suite each have one thing wrong, and you see all three in one pass instead of three pushes.

The security steps are two tools, and neither one is a penetration test

Brakeman reads your source. Run here with --summary it reports the scope precisely:

Rails Version: 8.1.3.1
Brakeman Version: 8.0.6
Duration: 1.880541 seconds
Controllers: 69
Models: 76
Templates: 215
Security Warnings: 0
Ignored Warnings: 1

79 named checks, listed in full in that output, covering SQL, CrossSiteScripting, Redirect, MassAssignment, UnsafeReflection, SessionSettings, EOLRails and seventy-two others. Static analysis over a parse tree, in under two seconds, with no database and no running server.

bundler-audit reads your lockfile. It diffs the gem versions in Gemfile.lock against ruby-advisory-db, which on this machine reported "1247 advisories, last updated 2026-09-23 19:47:20 -0400, commit fb34fedf8a96f99e54bcfb9306519996a70baa25". The whole check is 0.39 s.

Neither is a penetration test, and a Rails application ships nothing that is. A penetration test is somebody with an intent sending traffic at a running instance: session fixation attempts, forced browsing at object ids, header injection, timing the login form. The closest thing in the box is ActionDispatch::IntegrationTest, which sends real requests through the real middleware stack and is where you write the adversarial cases yourself. The next section is what that means in practice.

The hole Brakeman scores clean

An ArticlesController in the throwaway app, a Rails 8.1 scaffold trimmed to two actions with one line of ownership added to index:

class ArticlesController < ApplicationController
  before_action :set_article, only: %i[ show update ]

  def index
    @articles = Article.where(user_id: current_user_id)
  end

  def update
    if @article.update(article_params)
      redirect_to @article
    else
      render :edit, status: :unprocessable_content
    end
  end

  private
    def current_user_id
      session[:user_id]
    end

    def set_article
      @article = Article.find(params[:id])
    end
end

index scopes. set_article does not. Any signed-in session can PATCH any article in the table. Brakeman 8.0.6 over that application: Controllers: 2, Models: 2, Templates: 8, Security Warnings: 0, then No warnings found.

Seven lines of integration test find it:

test "a signed in user cannot update somebody else's article" do
  get "/login/1"
  theirs = articles(:theirs)
  patch article_url(theirs), params: { article: { title: "taken over" } }
  assert_response :not_found
  assert_equal "Theirs", theirs.reload.title
end
status: 302, title now: "taken over"
Expected response to be a <404: missing>, but was a <302: Found>

Add a genuine injection to the same file and Brakeman does its job immediately:

def search
  @articles = Article.where("title = '#{params[:q]}'")
end
Confidence: High
Category: SQL Injection
Check: SQL
Message: Possible SQL injection
Code: Article.where("title = '#{params[:q]}'")
File: app/controllers/articles_controller.rb
Line: 9

The line between those two results is worth stating plainly, because it decides what you have to write by hand. Brakeman recognises dangerous shapes. Missing authorisation has no shape: a scoped current_user.articles.find and an unscoped Article.find are the same expression with a different receiver, and no parse tree tells you which one this application intended. Every authorisation rule in your app is yours to test, the test is an integration test, and the assertion that matters is the negative one.

The dead end: a green test that proved nothing

The first version of the ownership test above passed. assert_response :not_found was green against a controller with no authorisation in it at all, and stopping there would have shipped a test that asserts a 404 arrives and never notices when a 302 replaces it.

log/test.log had the reason:

AbstractController::ActionNotFound (The destroy action could not be found for the :set_article
callback on ArticlesController, but it is listed in the controller's :only option.

The scaffold had been trimmed down to show and update with destroy left behind in the only: list. raise_on_missing_callback_actions has been a default since Rails 7.1 and it raises on the first request that touches the controller. In the test environment config.action_dispatch.show_exceptions = :rescuable turns that raise into a rendered 404, which is exactly what the assertion was looking for.

Two things follow. A status-code assertion is the weakest assertion in an integration test, because every misconfiguration in the stack arrives dressed as a status code. And a test asserting a failure should be run once against code that ought to pass it: green on the broken version is not a passing test, it is a test that has not been read.

Two ignore files, one gem audit, two answers

On this repository, today:

$ bundle exec bundle-audit check
No vulnerabilities found

$ bin/bundler-audit
Name: ruby_llm
Version: 1.16.0
CVE: CVE-2026-67991
GHSA: GHSA-42r3-x6vx-x49x
Criticality: High
Title: Polynomial-Time Regular Expression Denial of Service (ReDoS) vulnerability
Solution: update to '>= 2.0.0.rc1'

Vulnerabilities found!

Same bundler-audit 0.9.3, same Gemfile.lock, same advisory database. The difference is one flag. bin/bundler-audit is the Rails 8.1 template verbatim:

ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check")

and bundler-audit's own default, in cli.rb, is method_option :config, type: :string, aliases: '-c', default: '.bundler-audit.yml'. Two different filenames. This repository's accepted-risk list lives in .bundler-audit.yml, where the CVE-2026-67991 entry carries thirty lines explaining why the only published fix is a release candidate of a major version that renames two tables. config/bundler-audit.yml is the generated stub and its entire ignore list is CVE-THAT-DOES-NOT-APPLY.

So the audit that runs in bin/ci cannot see any exception this project has ever recorded, and bin/ci on this site fails on an advisory the team decided about in writing and committed. The bug is this repository's, not the framework's, and it is the failure mode to check for in any application older than Rails 8.1 that has since adopted bin/ci: your exceptions are in one file and your gate reads the other. Both filenames are plausible and neither tool warns that the other file exists.

Coverage is in the standard library

No gem is needed to measure line coverage in a Rails application. Ruby's Coverage module, required early enough through RUBYOPT so that it is running before config/environment loads anything, produces the numbers directly. A 15-line prelude over this suite reported:

app/ files instrumented: 220
relevant lines: 4023, executed: 3939, 97.9%
  helpers/application_helper.rb: 7 of 43 lines never ran
  controllers/sessions/omniauth_controller.rb: 5 of 29 lines never ran
  services/analytics/report.rb: 5 of 129 lines never ran

The cost is 6.6 s on a 28.2 s run, 34.86 s against 28.2 s wall. SimpleCov is a reporting layer on top of this, and it is worth its dependency for the HTML output and the per-group thresholds, not for the measurement.

What 97.9 percent is worth is a separate question, and the answer is less than it looks: line coverage records that a line executed, not that any assertion would notice if it changed. Testing what an agent wrote takes that apart with mutant against this same code.

The test type nobody puts in the list

spec/repository/ holds 22 examples that run in 0.221 s and assert nothing about the application. They read the repository as text: that every image in a content file resolves through the asset pipeline, that every image has alt text long enough to describe what it shows, that every published article has a social card committed, and that no em-dash or en-dash has reappeared anywhere in a tracked file.

Nothing in Rails suggests writing these and nothing stops you. They are cheap, because they need no database and no requests, and they catch the class of mistake that no amount of application testing reaches: a rule the team agreed on in a document and then stopped following.

What is announced and not here yet

herb:check compiles every HTML+ERB template through Herb and exits non-zero on the ones it rejects, announced in This Week in Rails on 2026-09-18 under rails/rails pull request 58770. It is not in railties 8.1.3.1: lib/rails/tasks/ contains eight rake files and none of them is herb, and bin/rails -T | grep -i herb returns nothing here. A template that raises at render time is currently caught by a view spec, a request spec or a user, in that order of preference.

The call, and what would change it

Reach for the cheapest layer that can see the defect, and know which layer that is before you write anything. Business rules and validations go in model specs at 6.2 ms. Anything about who is allowed to do what goes in an integration test, because authorisation has no shape a scanner recognises and the assertion has to be negative. N+1 queries and middleware behaviour go in request specs, because that is where the instruments are installed. The browser is for Stimulus, Turbo and things that are invisible to the server, at 130 ms and a flake budget.

Run bin/ci rather than rspec locally, and edit config/ci.rb until the local gate and the remote gate are the same list. Every project eventually grows a "but CI also runs X" problem, and the Rails 8.1 answer to it is a 20-line Ruby file that a person can read.

What would change this: a Rails release that makes system tests cheap enough to displace request specs. Most of the 130 ms is process startup and CDP round trips rather than anything Rails controls, so this is not close.

What this post does not cover

Which DSL to write the tests in, which is RSpec vs Minitest. Where the test data comes from, which is FactoryBot and Rails fixtures vs factories. What a browser test costs and how it goes flaky, which is Rails system tests. Contract testing against third-party APIs, which needs WebMock or VCR and is a gem decision, not a Rails one. Load testing: ApacheBench and k6 are outside the framework entirely and the numbers depend on your hosting, not your code. Parallel test runners, which are measured in the RSpec versus Minitest piece. And actual penetration testing, which is a person, a scope document and an engagement, not a rake task.

#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.