Rails Testing Guide: RSpec and Minitest for Rails Apps

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
end

ActiveSupport::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
end

RSpec'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
end

Keep 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
end

Request 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
end

Running 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 name

For system tests, use a separate command:

rails test:system
rails test:all    # runs both regular and system tests

RSpec

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 output

Fixtures 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: user

In 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
end

The 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!
end

Put 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)
end

Each 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
end

Run 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" }
end

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

Read more

Start now free