Testing
Rails testing with RSpec and Minitest, covering model specs, request specs, system tests, factories, and test design.
You are an expert in testing Ruby on Rails applications using RSpec, Minitest, and supporting libraries like FactoryBot, Capybara, and VCR. ## Key Points - Prefer `build` or `build_stubbed` over `create` when you do not need database persistence -- it is significantly faster. - Use `let` (lazy) by default; use `let!` (eager) only when the record must exist before the test runs. - Write request specs for controller logic; reserve system specs for critical user flows. - Keep factories minimal. Use traits for variations rather than deeply nested factory inheritance. - Run tests in parallel (`parallelize` in Minitest, `parallel_tests` gem for RSpec) to keep the suite fast. - Test behavior, not implementation. Assert on outcomes, not on which methods were called. - Use `freeze_time` or `travel_to` for time-dependent tests instead of stubbing `Time.now`. - **Slow test suites**: Overuse of `create` and system tests. Keep the test pyramid balanced: many unit tests, fewer integration tests, fewest system tests. - **Flaky system tests**: Race conditions from asynchronous JavaScript. Use Capybara's built-in waiting matchers (`have_text`, `have_selector`) instead of `sleep`. - **Factory cascades**: A single `create(:post)` that also creates a user, organization, and subscription. Audit factory dependencies. - **Testing Rails internals**: Do not test that `validates_presence_of` works. Test your custom logic and important business rules. - **Shared state between tests**: Forgetting `DatabaseCleaner` or transactional fixtures leads to order-dependent failures.
skilldb get ruby-rails-skills/TestingFull skill: 342 linesTesting — Ruby on Rails
You are an expert in testing Ruby on Rails applications using RSpec, Minitest, and supporting libraries like FactoryBot, Capybara, and VCR.
Overview
Rails ships with Minitest and includes a robust testing framework out of the box. RSpec is the most popular alternative, offering expressive syntax and a rich ecosystem. Effective Rails testing combines unit tests (models, services), integration tests (requests/controllers), and system tests (browser-driven) to build confidence without excessive test suites.
Core Philosophy
Tests exist to give you confidence to change code. A test suite that is slow, brittle, or tightly coupled to implementation details achieves the opposite — it makes developers afraid to refactor because every change breaks dozens of specs. The best Rails test suites are fast, focused on behavior, and structured as a pyramid: many unit tests, fewer integration tests, and a small number of system tests for critical user flows.
Test what your code does, not how it does it. Assert on return values, side effects, and observable behavior rather than on which internal methods were called or in what order. This distinction matters because implementation-focused tests break every time you refactor, even when the behavior is unchanged. Behavior-focused tests break only when you actually introduce a bug.
Speed is a feature of your test suite. Every second added to the feedback loop reduces how often developers run tests, which reduces confidence, which reduces code quality. Prefer build and build_stubbed over create, run tests in parallel, minimize system tests, and audit factory chains that create unnecessary associated records. A fast test suite is one that developers actually use.
Anti-Patterns
-
The Integration-Only Suite: Writing request specs or system tests for everything, including pure logic that could be tested at the unit level. Integration tests are slow and provide broad coverage. Unit tests are fast and provide precise feedback. You need both, but the ratio should heavily favor unit tests.
-
Factory Cascade Blindness: Calling
create(:post)without realizing it also creates a user, an organization, a subscription, and three associated records behind the scenes. Audit your factories to understand their dependency chains and usebuild_stubbedwhenever persistence is not required. -
Sleep-Based Synchronization: Adding
sleep 2in system tests to wait for asynchronous operations instead of using Capybara's built-in waiting matchers likehave_textorhave_selector. Sleep-based waits are both slow (they always wait the full duration) and flaky (they sometimes do not wait long enough). -
Testing Framework Behavior: Writing specs that validate
validates_presence_ofactually works, or thathas_manycreates the right SQL. These are tests of Rails itself, not of your application. Focus tests on your custom business rules, edge cases, and integration points. -
Shared Mutable State: Writing tests that depend on records created by other tests, or on a specific test execution order. Every test should set up its own state in
before/setupblocks and leave no trace when it finishes.
Core Concepts
RSpec Setup
# Gemfile
group :development, :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
end
group :test do
gem "shoulda-matchers"
gem "capybara"
gem "selenium-webdriver"
gem "webmock"
gem "vcr"
gem "simplecov", require: false
end
# spec/rails_helper.rb
require "spec_helper"
require "rspec/rails"
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
end
# spec/support/shoulda_matchers.rb
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
Minitest Setup
# test/test_helper.rb
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
fixtures :all
end
Implementation Patterns
Model Specs (RSpec)
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_length_of(:username).is_at_least(3) }
end
describe "associations" do
it { is_expected.to have_many(:posts).dependent(:destroy) }
it { is_expected.to have_one(:profile).dependent(:destroy) }
it { is_expected.to belong_to(:organization).optional }
end
describe "#full_name" do
it "combines first and last name" do
user = build(:user, first_name: "Jane", last_name: "Doe")
expect(user.full_name).to eq("Jane Doe")
end
end
describe "#active?" do
context "when the subscription is current" do
it "returns true" do
user = build(:user, subscription_expires_at: 1.month.from_now)
expect(user).to be_active
end
end
context "when the subscription has expired" do
it "returns false" do
user = build(:user, subscription_expires_at: 1.day.ago)
expect(user).not_to be_active
end
end
end
end
Model Tests (Minitest)
# test/models/user_test.rb
class UserTest < ActiveSupport::TestCase
test "valid with required attributes" do
user = User.new(email: "test@example.com", username: "testuser")
assert user.valid?
end
test "invalid without email" do
user = User.new(username: "testuser")
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
test "#full_name combines first and last name" do
user = users(:jane)
assert_equal "Jane Doe", user.full_name
end
end
Request Specs (RSpec)
# spec/requests/posts_spec.rb
RSpec.describe "Posts", type: :request do
let(:user) { create(:user) }
let(:valid_attrs) { { title: "Test Post", body: "Content here" } }
before { sign_in user }
describe "GET /posts" do
it "returns a successful response" do
create_list(:post, 3, user: user)
get posts_path
expect(response).to have_http_status(:ok)
end
end
describe "POST /posts" do
context "with valid parameters" do
it "creates a new post and redirects" do
expect {
post posts_path, params: { post: valid_attrs }
}.to change(Post, :count).by(1)
expect(response).to redirect_to(Post.last)
end
end
context "with invalid parameters" do
it "returns unprocessable entity" do
post posts_path, params: { post: { title: "" } }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "GET /posts/:id (JSON)" do
it "returns the post as JSON" do
post_record = create(:post, user: user)
get post_path(post_record), as: :json
expect(response).to have_http_status(:ok)
json = response.parsed_body
expect(json["title"]).to eq(post_record.title)
end
end
end
System Tests (Browser Tests)
# spec/system/user_registration_spec.rb
RSpec.describe "User Registration", type: :system do
before { driven_by(:selenium_chrome_headless) }
it "allows a new user to register" do
visit new_user_registration_path
fill_in "Email", with: "new@example.com"
fill_in "Password", with: "securepassword123"
fill_in "Password confirmation", with: "securepassword123"
click_button "Sign up"
expect(page).to have_text("Welcome! You have signed up successfully.")
expect(User.find_by(email: "new@example.com")).to be_present
end
it "shows inline validation errors" do
visit new_user_registration_path
click_button "Sign up"
expect(page).to have_text("Email can't be blank")
end
end
Factories (FactoryBot)
# spec/factories/users.rb
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
username { Faker::Internet.username(specifier: 5..20) }
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
password { "password123" }
trait :admin do
role { :admin }
end
trait :with_posts do
transient do
posts_count { 3 }
end
after(:create) do |user, ctx|
create_list(:post, ctx.posts_count, user: user)
end
end
trait :expired do
subscription_expires_at { 1.day.ago }
end
factory :admin_user, traits: [:admin]
end
end
# Usage
create(:user)
create(:user, :admin, :with_posts, posts_count: 5)
build_stubbed(:user) # No database hit
Shared Examples
# spec/support/shared_examples/authenticatable.rb
RSpec.shared_examples "requires authentication" do
it "redirects unauthenticated users" do
sign_out :user
make_request
expect(response).to redirect_to(new_user_session_path)
end
end
# Usage in specs
RSpec.describe "Posts", type: :request do
describe "POST /posts" do
it_behaves_like "requires authentication" do
let(:make_request) { post posts_path, params: { post: { title: "X" } } }
end
end
end
Mocking External APIs
# Using WebMock
RSpec.describe PaymentService do
before do
stub_request(:post, "https://api.stripe.com/v1/charges")
.with(body: hash_including(amount: "1000"))
.to_return(status: 200, body: { id: "ch_123" }.to_json)
end
it "creates a charge" do
result = PaymentService.charge(amount: 1000, currency: "usd")
expect(result.id).to eq("ch_123")
end
end
# Using VCR for recorded HTTP interactions
RSpec.describe GithubClient do
it "fetches repository info", vcr: { cassette_name: "github/repo" } do
repo = GithubClient.new.repository("rails/rails")
expect(repo[:full_name]).to eq("rails/rails")
end
end
Best Practices
- Prefer
buildorbuild_stubbedovercreatewhen you do not need database persistence -- it is significantly faster. - Use
let(lazy) by default; uselet!(eager) only when the record must exist before the test runs. - Write request specs for controller logic; reserve system specs for critical user flows.
- Keep factories minimal. Use traits for variations rather than deeply nested factory inheritance.
- Run tests in parallel (
parallelizein Minitest,parallel_testsgem for RSpec) to keep the suite fast. - Test behavior, not implementation. Assert on outcomes, not on which methods were called.
- Use
freeze_timeortravel_tofor time-dependent tests instead of stubbingTime.now.
Common Pitfalls
- Slow test suites: Overuse of
createand system tests. Keep the test pyramid balanced: many unit tests, fewer integration tests, fewest system tests. - Flaky system tests: Race conditions from asynchronous JavaScript. Use Capybara's built-in waiting matchers (
have_text,have_selector) instead ofsleep. - Factory cascades: A single
create(:post)that also creates a user, organization, and subscription. Audit factory dependencies. - Testing Rails internals: Do not test that
validates_presence_ofworks. Test your custom logic and important business rules. - Shared state between tests: Forgetting
DatabaseCleaneror transactional fixtures leads to order-dependent failures. - Brittle mocks: Over-mocking hides integration bugs. Mock at the boundary (HTTP calls, external services), not between your own classes.
Install this skill directly: skilldb add ruby-rails-skills
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.