Testing Rails Applications: From Zero to Confidence

Models, controllers, requests, system tests, mailers, jobs. Everything you need to know about testing Rails applications. With real examples, factory setup, and continuous integration

Testing Rails Applications: From Zero to Confidence

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

CODE
        /\
       /  \     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

RUBY
# Gemfile
group :development, :test do
  gem "rspec-rails", "~> 6.0"
  gem "factory_bot_rails"
  gem "faker"
end
BASH
bundle install
rails generate rspec:install

Configuration

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

CODE
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

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

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

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

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

RUBY
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

RUBY
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

RUBY
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

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

RUBY
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

RUBY
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

RUBY
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

RUBY
# spec/system/features_helper.rb
require "rails_helper"
require "capybara/rspec"

RSpec.configure do |config|
  config.include Capybara::DSL
end
RUBY
# 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

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

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

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

RUBY
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

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

RUBY
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

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

RUBY
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

RUBY
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

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

RUBY
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

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

RUBY
# 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:

RUBY
# 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:

RUBY
# 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:

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

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


DO

Derrick Orare

Software Engineer

Discussion

0 Comments

Questions, feedback or corrections are welcome.

💬

No discussion yet.

Be the first to share your thoughts.

Leave a comment