Rails Testing Guide: RSpec and Minitest for Rails Apps
Rails ships with a testing framework built in. Most teams either use it as-is (Minitest) or replace it with RSpec. Both are solid choices, but they have different philosophies and different ecosystems. This guide covers what you need to know to test a Rails application effectively — regardless of which framework you choose.
Minitest vs RSpec
Rails uses Minitest by default. When you run rails generate model User, Rails creates test/models/user_test.rb automatically:
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "should be valid with a name and email" do
user = User.new(name: "Alice", email: "alice@example.com")
assert user.valid?
end
test "should require an email" do
user = User.new(name: "Alice")
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
endActiveSupport::TestCase wraps Minitest and adds Rails-specific helpers. The test block is syntactic sugar over def test_should_be_valid_with_a_name_and_email.
RSpec takes a different approach. The same tests look like this:
RSpec.describe User, type: :model do
context "with valid attributes" do
it "is valid" do
user = build(:user)
expect(user).to be_valid
end
end
context "without an email" do
it "is invalid" do
user = build(:user, email: nil)
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
end
endRSpec's describe/context/it structure encourages hierarchical organization. The tradeoff: more boilerplate to set up, but the structure scales better on large codebases.
When to choose Minitest: small teams, Rails-first mindset, no strong opinions about BDD syntax.
When to choose RSpec: teams coming from other ecosystems, preference for BDD-style organization, heavy use of shared examples or custom matchers.
Test Types in Rails
Rails organizes tests into several categories. Understanding what belongs where saves a lot of confusion.
Unit Tests (Models)
Model tests live in test/models/ or spec/models/. They test business logic in isolation — validations, callbacks, scopes, and custom methods.
class Order < ApplicationRecord
scope :recent, -> { where("created_at > ?", 7.days.ago) }
scope :pending, -> { where(status: "pending") }
def total_with_tax
subtotal * (1 + tax_rate)
end
end
# test
RSpec.describe Order, type: :model do
describe ".recent" do
it "returns orders from the last 7 days" do
recent = create(:order, created_at: 3.days.ago)
old = create(:order, created_at: 10.days.ago)
expect(Order.recent).to include(recent)
expect(Order.recent).not_to include(old)
end
end
describe "#total_with_tax" do
it "applies the tax rate to the subtotal" do
order = build(:order, subtotal: 100.0, tax_rate: 0.08)
expect(order.total_with_tax).to eq(108.0)
end
end
endKeep model tests focused on model behavior. Don't make HTTP requests or interact with controllers here.
Integration Tests (Request Specs)
Request specs (RSpec) or integration tests (Minitest) test the full stack from routing through the controller to the response. They don't render views in the browser — they return the response object directly.
RSpec.describe "Orders API", type: :request do
describe "POST /orders" do
context "with valid params" do
it "creates an order and returns 201" do
post "/orders", params: { order: { product_id: product.id, quantity: 2 } },
headers: auth_headers
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)["status"]).to eq("pending")
end
end
end
endRequest specs replaced controller specs as the recommended way to test controllers in Rails. They're faster than system tests and test more than unit tests.
System Tests
System tests use a real browser (via Capybara) to simulate user interactions. They're the slowest but catch integration bugs that request specs miss.
class UserRegistrationTest < ApplicationSystemTestCase
test "user can register" do
visit new_user_registration_path
fill_in "Email", with: "alice@example.com"
fill_in "Password", with: "securepassword"
click_on "Sign up"
assert_text "Welcome, alice@example.com"
end
endRunning Tests
rails test
The built-in test runner handles all Minitest tests:
rails test # run all tests
rails test test/models/ # run model tests only
rails test test/models/user_test.rb:12 # run test at line 12
rails test -n "test_should_be_valid" # run by nameFor system tests, use a separate command:
rails test:system
rails test:all # runs both regular and system testsRSpec
bundle exec rspec # run all specs
bundle exec rspec spec/models/ # models only
bundle exec rspec spec/models/user_spec.rb:15 # specific line
bundle exec rspec --format documentation # verbose outputFixtures vs Factories
Rails fixtures are YAML files that pre-populate the database before tests run:
# test/fixtures/users.yml
alice:
name: Alice
email: alice@example.com
role: admin
bob:
name: Bob
email: bob@example.com
role: userIn tests, reference them with users(:alice). Fixtures are fast because they load once per test suite, but they're brittle — changing a fixture can break dozens of tests.
Factories (via FactoryBot) build records on demand:
FactoryBot.define do
factory :user do
name { "Alice" }
sequence(:email) { |n| "user#{n}@example.com" }
role { :user }
trait :admin do
role { :admin }
end
end
end
# in tests
user = create(:user)
admin = create(:user, :admin)Factories are slower (each create hits the database) but more explicit. Each test builds exactly what it needs, making tests easier to understand and more resilient to change.
Recommendation: use factories with FactoryBot. The flexibility is worth the speed cost in most applications.
database_cleaner
By default, Rails wraps each test in a transaction and rolls it back after the test. This is fast but breaks down when:
- Tests use multiple threads (like system tests with a JS browser driver)
- Tests explicitly commit transactions
- You need to test database-specific behavior like callbacks
database_cleaner gives you explicit control over database cleanup strategy:
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
config.before(:each, type: :system) do
DatabaseCleaner.strategy = :truncation
end
config.after(:each, type: :system) do
DatabaseCleaner.strategy = :transaction
end
endThe key insight: use :transaction for fast unit and request tests, :truncation for system tests that run in a separate thread.
Test Helper Setup
Rails provides test/test_helper.rb (Minitest) and spec/spec_helper.rb plus spec/rails_helper.rb (RSpec) as central configuration points.
A typical rails_helper.rb setup:
require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rspec/rails"
Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |f| require f }
RSpec.configure do |config|
config.fixture_path = "#{::Rails.root}/spec/fixtures"
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
endPut shared helpers and configuration in spec/support/. Common files: database_cleaner.rb, factory_bot.rb, capybara.rb, devise.rb (for auth helpers).
Parallel Tests
Rails 6+ supports parallel tests out of the box:
# test/test_helper.rb
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
endEach worker gets its own database (myapp_test_1, myapp_test_2, etc.). Rails handles database creation and schema loading automatically.
For RSpec, use parallel_tests gem:
bundle exec parallel_rspec spec/Parallel tests dramatically reduce suite time on multi-core machines. A 10-minute suite can drop to 3 minutes with 4 workers.
Code Coverage
SimpleCov tracks which lines your tests execute:
# spec/spec_helper.rb (add at the very top)
require "simplecov"
SimpleCov.start "rails" do
add_filter "/spec/"
add_filter "/config/"
minimum_coverage 90
endRun your test suite and open coverage/index.html. SimpleCov shows which files and lines are covered, and can fail the build if coverage drops below a threshold.
What to Test
A pragmatic testing strategy for Rails apps:
- Models: validate all validations, test scopes with boundary conditions, test custom methods
- Request specs: one happy path per action, one error case per action, authentication boundaries
- System tests: critical user journeys (registration, checkout, core workflows) — not every screen
- Jobs/Mailers: test they enqueue correctly, test the job performs its side effect
Don't test Rails internals. Testing that has_many :orders works is testing Rails, not your code. Test the behavior your code adds on top of Rails.
Continuous Testing
Run tests automatically as you work with Guard:
# Guardfile
guard :rspec, cmd: "bundle exec rspec" do
watch(%r{spec/.+_spec\.rb})
watch(%r{app/(.+)\.rb}) { |m| "spec/#{m[1]}_spec.rb" }
watch("spec/rails_helper.rb") { "spec" }
endbundle exec guard keeps a process running that reruns relevant specs on file save. Fast feedback without manual intervention.
Teams that want automated end-to-end coverage running continuously alongside their Rails test suite use HelpMeTest to monitor critical user flows in production — complementing the unit and integration tests that live in the repo.