You know you should test your code. Everyone says so.
But where do you start? What do you test? How do you test it?
Most developers write tests because they have to, not because they want to. They struggle with setup, fight with factories, and write brittle tests that break every time they change something.
It doesn't have to be this way.
Good tests are the difference between confidently deploying on Friday and spending the weekend fixing bugs. They give you freedom to refactor. They document your code. They catch mistakes before your users do.
Let me show you how to test Rails applications properly.
The Short Answer
Test everything that could break.
That sounds simple, but it's the most important rule.
- Models: Validations, scopes, custom methods
- Controllers/Requests: Endpoints, authentication, responses
- Views: Critical UI elements (optional)
- System: Complete user flows
- Mailers: Content, delivery, attachments
- Jobs: Enqueueing, execution, retries
Use RSpec, Factory Bot, and Capybara. Run tests in CI. Never deploy red.
Part 1: Why Test?
Before we write any tests, let's understand why we test.
Confidence
Tests give you confidence to change code. Without tests, every change is risky. With tests, you know immediately if you broke something.
I can confidently deploy on Friday because my test suite is green.
- Every good developer
Documentation
Tests document your code better than comments. They show how your code is supposed to work. They provide real examples of usage.
Regression Prevention
Tests catch bugs before they reach production. When you fix a bug, write a test for it. That bug will never come back.
Design Improvement
Testable code is often better code. If something is hard to test, it's probably too complex. Testing forces you to separate concerns.
Part 2: Test Types in Rails
Rails has several layers of testing. Each serves a different purpose.
| Test Type | What It Tests | Speed | Use When |
|---|---|---|---|
| Models | Validations, scopes, methods | Fast | Every model |
| Controllers | Routing, responses, cookies | Medium | Critical endpoints |
| Requests | End-to-end API behavior | Medium | Most applications |
| System | Full user flows, JavaScript | Slow | Critical flows |
| Mailers | Email content, delivery | Medium | All mailers |
| Jobs | Enqueueing, execution, retries | Medium | All jobs |
The Testing Pyramid
/\
/ \ System Tests (few)
/ \
/______\
/ \ Controller/Request Tests (some)
/ \
/____________\
/ \ Model Tests (many)
/________________\
Rule: More model tests, fewer system tests.
Part 3: Setting Up RSpec
Rails comes with Minitest by default. But most developers prefer RSpec.
Installation
# Gemfile
group :development, :test do
gem "rspec-rails", "~> 6.0"
gem "factory_bot_rails"
gem "faker"
end
bundle install
rails generate rspec:install
Configuration
# spec/rails_helper.rb
require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rspec/rails"
require "factory_bot_rails"
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
# Run each test in a database transaction
config.use_transactional_fixtures = true
# Run tests in random order
config.order = :random
# Automatically infer test type from file location
config.infer_spec_type_from_file_location!
end
Directory Structure
spec/
├── models/ # Model tests
├── controllers/ # Controller tests
├── requests/ # Request tests
├── system/ # System tests
├── mailers/ # Mailer tests
├── jobs/ # Job tests
├── factories/ # Factory definitions
├── support/ # Helpers and configuration
├── rails_helper.rb
└── spec_helper.rb
Part 4: Factory Bot: Creating Test Data
Factory Bot replaces Rails fixtures. It's cleaner, more flexible, and works with relationships.
Creating Factories
# spec/factories/users.rb
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
password { "password123" }
password_confirmation { "password123" }
name { Faker::Name.name }
active { true }
created_at { 1.day.ago }
# Traits for variations
trait :admin do
admin { true }
end
trait :inactive do
active { false }
end
trait :with_orders do
after(:create) do |user|
create_list(:order, 3, user: user)
end
end
end
end
Using Factories in Tests
# Create a user
user = create(:user)
# Create with specific attributes
user = create(:user, email: "alice@example.com", name: "Alice")
# Use traits
admin = create(:user, :admin)
inactive_user = create(:user, :inactive)
# Build without saving (useful for validation tests)
user = build(:user, email: "invalid") # Not saved to database
# Get a user without creating (for associations)
user = build_stubbed(:user) # Simulated record, not saved
# Create multiple records
users = create_list(:user, 10)
# Create with associations
order = create(:order, user: user)
Associations in Factories
# spec/factories/orders.rb
FactoryBot.define do
factory :order do
user
total_cents { 4999 }
status { "pending" }
created_at { Time.current }
trait :completed do
status { "completed" }
completed_at { Time.current }
end
trait :with_items do
after(:create) do |order|
create_list(:order_item, 3, order: order)
end
end
end
end
Part 5: Model Tests
Model tests are the foundation of your test suite. They're fast and catch most bugs.
Testing Validations
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe User, type: :model do
describe "validations" do
it "is valid with valid attributes" do
user = build(:user)
expect(user).to be_valid
end
it "requires an email" do
user = build(:user, email: nil)
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
it "requires a unique email" do
create(:user, email: "alice@example.com")
duplicate = build(:user, email: "alice@example.com")
expect(duplicate).not_to be_valid
expect(duplicate.errors[:email]).to include("has already been taken")
end
it "requires a valid email format" do
user = build(:user, email: "invalid")
expect(user).not_to be_valid
expect(user.errors[:email]).to include("is invalid")
end
it "requires a password" do
user = build(:user, password: nil)
expect(user).not_to be_valid
expect(user.errors[:password]).to include("can't be blank")
end
it "requires a password of at least 6 characters" do
user = build(:user, password: "12345")
expect(user).not_to be_valid
expect(user.errors[:password]).to include("is too short (minimum is 6 characters)")
end
end
end
Testing Scopes
RSpec.describe User, type: :model do
describe "scopes" do
describe ".active" do
it "returns only active users" do
active_user = create(:user, active: true)
inactive_user = create(:user, active: false)
expect(User.active).to include(active_user)
expect(User.active).not_to include(inactive_user)
end
end
describe ".admins" do
it "returns only admin users" do
admin = create(:user, :admin)
regular = create(:user)
expect(User.admins).to include(admin)
expect(User.admins).not_to include(regular)
end
end
end
end
Testing Custom Methods
RSpec.describe Order, type: :model do
describe "#total" do
it "sums the total of all items" do
order = create(:order)
create(:order_item, order: order, price_cents: 1000, quantity: 2)
create(:order_item, order: order, price_cents: 500, quantity: 1)
expect(order.total).to eq(2500)
end
it "returns 0 when there are no items" do
order = create(:order)
expect(order.total).to eq(0)
end
end
describe "#paid?" do
it "returns true when status is paid" do
order = create(:order, status: "paid")
expect(order).to be_paid
end
it "returns false when status is pending" do
order = create(:order, status: "pending")
expect(order).not_to be_paid
end
end
end
Testing Edge Cases
RSpec.describe User, type: :model do
describe "#full_name" do
it "combines first and last name" do
user = build(:user, first_name: "Alice", last_name: "Smith")
expect(user.full_name).to eq("Alice Smith")
end
it "returns first name when last name is missing" do
user = build(:user, first_name: "Alice", last_name: nil)
expect(user.full_name).to eq("Alice")
end
it "returns last name when first name is missing" do
user = build(:user, first_name: nil, last_name: "Smith")
expect(user.full_name).to eq("Smith")
end
it "returns 'Unknown' when both names are missing" do
user = build(:user, first_name: nil, last_name: nil)
expect(user.full_name).to eq("Unknown")
end
end
end
Part 6: Request Tests
Request tests replace controller tests. They test your API endpoints end-to-end.
Basic Request Tests
# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users", type: :request do
describe "GET /users" do
it "returns a list of users" do
create_list(:user, 3)
get users_path
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).size).to eq(3)
end
end
describe "POST /users" do
let(:valid_params) do
{ user: { email: "alice@example.com", name: "Alice", password: "password123" } }
end
it "creates a user" do
expect {
post users_path, params: valid_params
}.to change(User, :count).by(1)
end
it "returns the created user" do
post users_path, params: valid_params
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)["email"]).to eq("alice@example.com")
end
it "returns errors when invalid" do
post users_path, params: { user: { email: "", name: "" } }
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["errors"]).to be_present
end
end
end
Testing Authentication
RSpec.describe "Orders", type: :request do
let(:user) { create(:user) }
let(:headers) { { "Authorization" => "Bearer #{generate_token(user)}" } }
describe "GET /orders" do
it "returns orders for the current user" do
create_list(:order, 3, user: user)
other_user = create(:user)
create_list(:order, 2, user: other_user)
get orders_path, headers: headers
expect(JSON.parse(response.body).size).to eq(3)
end
it "returns unauthorized when not logged in" do
get orders_path
expect(response).to have_http_status(:unauthorized)
end
end
end
Testing JSON Responses
RSpec.describe "Products", type: :request do
describe "GET /products/:id" do
let(:product) { create(:product, name: "Laptop", price: 999.99) }
it "returns the product with correct attributes" do
get product_path(product)
json = JSON.parse(response.body)
expect(json["name"]).to eq("Laptop")
expect(json["price"]).to eq(999.99)
expect(json["id"]).to eq(product.id)
end
it "does not include sensitive attributes" do
get product_path(product)
json = JSON.parse(response.body)
expect(json).not_to have_key("cost_price")
expect(json).not_to have_key("profit_margin")
end
end
end
Testing Non-JSON Responses
RSpec.describe "Pages", type: :request do
describe "GET /" do
it "renders the home page" do
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Welcome")
end
it "renders the correct layout" do
get root_path
expect(response.body).to include('data-theme="dark"')
end
end
end
Part 7: System Tests
System tests simulate real user interactions with your app. They're the closest thing to manual testing.
Setup
# spec/system/features_helper.rb
require "rails_helper"
require "capybara/rspec"
RSpec.configure do |config|
config.include Capybara::DSL
end
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by :rack_test # Fast, no JavaScript
end
config.before(:each, :js, type: :system) do
driven_by :selenium_chrome_headless # JavaScript support
end
end
User Authentication Flow
# spec/system/authentication_spec.rb
require "rails_helper"
RSpec.describe "Authentication", type: :system do
let(:user) { create(:user, email: "alice@example.com", password: "password123") }
scenario "user logs in successfully" do
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "password123"
click_button "Log In"
expect(page).to have_content("Welcome back, #{user.name}!")
expect(page).to have_current_path(dashboard_path)
end
scenario "user sees error with invalid credentials" do
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "wrongpassword"
click_button "Log In"
expect(page).to have_content("Invalid email or password")
expect(page).to have_current_path(login_path)
end
scenario "user logs out successfully" do
login_as(user)
visit dashboard_path
click_button "Log Out"
expect(page).to have_content("Logged out successfully")
expect(page).to have_current_path(root_path)
end
end
Order Flow
# spec/system/order_flow_spec.rb
require "rails_helper"
RSpec.describe "Order Flow", type: :system do
let(:user) { create(:user) }
let(:product) { create(:product, name: "Laptop", price: 999.99, stock: 5) }
before do
login_as(user)
end
scenario "user places an order" do
visit product_path(product)
click_button "Add to Cart"
expect(page).to have_content("Item added to cart")
visit cart_path
expect(page).to have_content("Laptop")
expect(page).to have_content("$999.99")
click_button "Checkout"
fill_in "Shipping Address", with: "123 Main St"
fill_in "Card Number", with: "4242424242424242"
fill_in "Expiry", with: "12/25"
fill_in "CVC", with: "123"
click_button "Place Order"
expect(page).to have_content("Order placed successfully!")
expect(page).to have_content("Order #")
expect(page).to have_content("$999.99")
# Check order was created
order = Order.last
expect(order.user).to eq(user)
expect(order.total).to eq(999.99)
expect(order.items.count).to eq(1)
end
scenario "user sees error when product is out of stock" do
product.update(stock: 0)
visit product_path(product)
expect(page).to have_content("Out of Stock")
expect(page).not_to have_button("Add to Cart")
end
end
JavaScript Testing
# spec/system/interactive_features_spec.rb
RSpec.describe "Interactive Features", type: :system, js: true do
scenario "user adds comment without page refresh" do
user = create(:user)
post = create(:post)
login_as(user)
visit post_path(post)
fill_in "Comment", with: "Great post!"
click_button "Submit"
# Wait for Turbo Stream to update the page
expect(page).to have_content("Great post!")
expect(page).to have_selector(".comment", count: 1)
end
scenario "user searches products in real-time" do
create(:product, name: "Laptop")
create(:product, name: "Mouse")
create(:product, name: "Keyboard")
visit products_path
fill_in "Search", with: "Lap"
# Wait for search results to update
expect(page).to have_content("Laptop")
expect(page).not_to have_content("Mouse")
expect(page).not_to have_content("Keyboard")
end
end
Testing with Capybara Matchers
RSpec.describe "Dashboard", type: :system do
scenario "user sees dashboard stats" do
login_as(user)
visit dashboard_path
# Using Capybara matchers
expect(page).to have_selector("h1", text: "Dashboard")
expect(page).to have_selector(".stat", count: 4)
expect(page).to have_selector(".stat .number", text: /\d+/)
# Within a specific element
within(".recent-orders") do
expect(page).to have_selector("tr", count: 5)
expect(page).to have_link("Order #123")
end
# Find specific element
expect(page).to have_css('div[data-testid="welcome-message"]')
end
end
Part 8: Mailer Tests
Testing mailers ensures your emails are sent correctly with the right content.
Basic Mailer Tests
# spec/mailers/user_mailer_spec.rb
require "rails_helper"
RSpec.describe UserMailer, type: :mailer do
let(:user) { create(:user, name: "Alice", email: "alice@example.com") }
let(:mail) { UserMailer.welcome_email(user) }
describe "#welcome_email" do
it "renders the subject" do
expect(mail.subject).to eq("Welcome to MyApp, Alice!")
end
it "renders the receiver email" do
expect(mail.to).to eq(["alice@example.com"])
end
it "renders the sender email" do
expect(mail.from).to eq(["noreply@myapp.com"])
end
it "includes the user's name" do
expect(mail.body.encoded).to include("Hello, Alice")
end
it "includes a link to the dashboard" do
expect(mail.body.encoded).to include(dashboard_url)
end
it "has both HTML and plain text parts" do
expect(mail.parts.length).to eq(2)
expect(mail.parts.first.content_type).to include("text/html")
expect(mail.parts.last.content_type).to include("text/plain")
end
end
end
Testing Links
RSpec.describe UserMailer, type: :mailer do
describe "#password_reset" do
let(:user) { create(:user) }
let(:mail) { UserMailer.password_reset(user) }
it "includes the password reset link" do
expect(mail.body.encoded).to include(edit_password_reset_url(user.reset_token))
end
it "includes the reset token in the link" do
expect(mail.body.encoded).to include(user.reset_token)
end
end
end
Testing Attachments
RSpec.describe InvoiceMailer, type: :mailer do
let(:invoice) { create(:invoice, :with_pdf) }
let(:mail) { InvoiceMailer.invoice_email(invoice.user, invoice) }
it "attaches the PDF" do
expect(mail.attachments.count).to eq(1)
expect(mail.attachments.first.content_type).to include("application/pdf")
expect(mail.attachments.first.filename).to eq("invoice_#{invoice.id}.pdf")
end
end
Part 9: Job Tests
Testing jobs ensures they're enqueued correctly and execute properly.
Testing Enqueueing
# spec/jobs/welcome_email_job_spec.rb
require "rails_helper"
RSpec.describe WelcomeEmailJob, type: :job do
let(:user) { create(:user) }
it "enqueues the job" do
expect {
WelcomeEmailJob.perform_later(user.id)
}.to have_enqueued_job(WelcomeEmailJob).with(user.id)
end
it "sends the email" do
expect {
WelcomeEmailJob.perform_now(user.id)
}.to change(ActionMailer::Base.deliveries, :count).by(1)
end
end
Testing Job Execution
RSpec.describe ProcessOrderJob, type: :job do
let(:order) { create(:order, status: "pending") }
it "updates the order status" do
expect {
ProcessOrderJob.perform_now(order.id)
}.to change { order.reload.status }.from("pending").to("processed")
end
it "handles missing order gracefully" do
expect {
ProcessOrderJob.perform_now(999999)
}.not_to raise_error
end
end
Part 10: Test Organization
Using Contexts
RSpec.describe Order, type: :model do
describe "#total" do
context "when order has no items" do
it "returns 0" do
order = create(:order)
expect(order.total).to eq(0)
end
end
context "when order has items" do
it "returns the sum of item totals" do
order = create(:order)
create(:order_item, order: order, price_cents: 1000, quantity: 2)
expect(order.total).to eq(2000)
end
end
end
end
Shared Examples
# spec/support/shared_examples.rb
RSpec.shared_examples "archivable" do
it "can be archived" do
expect(subject).to respond_to(:archive)
expect(subject).to respond_to(:unarchive)
expect(subject).to respond_to(:archived?)
end
it "sets archived_at when archived" do
expect { subject.archive }.to change { subject.archived_at }.from(nil)
end
end
# In tests
RSpec.describe User, type: :model do
it_behaves_like "archivable"
end
Tagging Tests
RSpec.describe "Slow tests", :slow do
# Tests that take longer to run
end
# Run only slow tests
rspec --tag slow
# Run everything except slow tests
rspec --tag ~slow
Part 11: Continuous Integration
GitHub Actions Setup
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: railsuser
POSTGRES_DB: myapp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2.0
bundler-cache: true
- name: Install dependencies
run: bundle install
- name: Setup database
run: |
bundle exec rails db:create
bundle exec rails db:schema:load
env:
RAILS_ENV: test
DATABASE_URL: postgresql://railsuser:password@localhost:5432/myapp_test
- name: Run tests
run: bundle exec rspec
env:
RAILS_ENV: test
DATABASE_URL: postgresql://railsuser:password@localhost:5432/myapp_test
REDIS_URL: redis://localhost:6379/0
Part 12: Common Pitfalls and Solutions
Pitfall 1: Tests That Are Too Slow
Problem: Test suite takes 10 minutes to run.
Solution:
# Use parallel testing
rails test:parallel
# Use clean database between tests
config.use_transactional_fixtures = true
# Mock external services
allow(Stripe).to receive(:charge).and_return(true)
Pitfall 2: Brittle Tests
Problem: Tests break every time you change the UI.
Solution:
# Use data attributes instead of text
it "shows the user name" do
expect(page).to have_css('[data-testid="user-name"]', text: "Alice")
end
# Use finders that don't depend on UI text
within(".user-card") do
expect(page).to have_content("Alice")
end
Pitfall 3: Not Testing Edge Cases
Problem: Tests only cover happy path.
Solution:
# Test error cases too
it "handles invalid data gracefully" do
# ...
end
# Test edge cases
it "works with empty arrays" do
# ...
end
# Test with real-world data
it "handles unicode characters" do
# ...
end
Pitfall 4: Leaky State
Problem: Tests affect each other.
Solution:
# Clean database between tests
config.use_transactional_fixtures = true
# Clean up in after blocks
after(:each) do
# Clean up created records
end
# Use factories that build new records each time
The Testing Checklist
Model Tests
- [ ] Validations (presence, uniqueness, format)
- [ ] Scopes (where, order, limit)
- [ ] Custom methods (with and without arguments)
- [ ] Edge cases (nil, empty, large values)
Request Tests
- [ ] HTTP status codes (200, 201, 404, 401, 422)
- [ ] JSON response structure
- [ ] Authentication (logged in, not logged in)
- [ ] Authorization (correct user, wrong user)
- [ ] Error handling
System Tests
- [ ] Complete user flows (signup, login, checkout)
- [ ] Critical paths (dashboard, settings)
- [ ] JavaScript interactions (Turbo, Stimulus)
- [ ] Error messages and validations
Mailer Tests
- [ ] Subject, to, from
- [ ] HTML and plain text content
- [ ] Links (absolute URLs)
- [ ] Attachments (if any)
- [ ] Conditional content
Job Tests
- [ ] Enqueueing (perform_later)
- [ ] Execution (perform_now)
- [ ] Retry logic
- [ ] Error handling
Summary
Testing Rails applications is not optional. It's how you ship with confidence.
| Test Type | Focus | Speed |
|---|---|---|
| Models | Validations, scopes, methods | Fast |
| Requests | Endpoints, responses, auth | Medium |
| System | Full user flows | Slow |
| Mailers | Content, delivery | Medium |
| Jobs | Enqueueing, execution | Medium |
Quick Start
# Generate RSpec
rails generate rspec:install
# Create a factory
rails generate factory_bot:model user
# Write a test
# spec/models/user_spec.rb
# Run tests
bundle exec rspec
# Run specific tests
bundle exec rspec spec/models/user_spec.rb
The Golden Rule
Test everything that could break. Don't test what can't break.
Tests should give you confidence to deploy. If they don't, they're not doing their job.
Start small. Write tests for new features. Gradually build a test suite that protects your app.
Your future self will thank you.
Leave a comment