Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 038 / 100

Testing in Rails: Ruby on Rails best practices explained

A complete testing setup.

Expona Admin9 min read

By day 38 of any serious Rails project, your test suite becomes your safety net—preventing regressions, documenting behavior, and catching bugs before production. Testing in Rails is not optional; it's the difference between shipping with confidence and debugging in the dark. In this guide, we'll walk through a complete testing setup that covers models, controllers, system tests, and test data strategies.

Why a Complete Testing Strategy Matters

Rails gives you the tools out of the box, but many teams either skip testing or implement it inconsistently. You end up with flaky tests, brittle specs that break on refactors, and false confidence in your codebase. The best practice is to layer your tests: unit tests catch logic errors quickly, request tests verify your API contract, and system tests simulate real user journeys. Combined with solid test data management, you get both speed and reliability.

Model Tests — Validations and Business Methods

Model tests are your foundation. They run fast, fail fast, and give you immediate feedback on business logic. Start with validations: these are the guardrails that prevent invalid data from entering your system.

ruby
require 'rails_helper'

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) }
    it { is_expected.to validate_presence_of(:password) }
    it { is_expected.to validate_length_of(:password).is_at_least(8) }
    
    it 'validates email format' do
      user = build(:user, email: 'invalid-email')
      expect(user).not_to be_valid
      expect(user.errors[:email]).to include('is invalid')
    end
  end

  describe '#activate!' do
    it 'sets activated_at and activated status' do
      user = create(:user, activated_at: nil, activated: false)
      user.activate!
      
      expect(user.activated).to be true
      expect(user.activated_at).not_to be_nil
    end
  end

  describe '#subscription_active?' do
    it 'returns true when subscription has not expired' do
      user = create(:user, subscription_expires_at: 1.day.from_now)
      expect(user.subscription_active?).to be true
    end

    it 'returns false when subscription has expired' do
      user = create(:user, subscription_expires_at: 1.day.ago)
      expect(user.subscription_active?).to be false
    end
  end
end

Notice how we test both the happy path and edge cases. For complex business logic, break it into smaller methods and test each one independently. A common pattern is testing state transitions: when a user completes an action, what changes? If you're using a gem like shoulda-matchers, you can express validations declaratively, which keeps specs readable and maintainable.

Model tests should also cover associations and scopes. If you have a Purchase model with a has_many :items relationship, verify the association behaves correctly under edge cases.

ruby
describe Purchase, type: :model do
  describe 'associations' do
    it { is_expected.to have_many(:items).dependent(:destroy) }
  end

  describe '.completed' do
    it 'returns only purchases with status completed' do
      completed = create(:purchase, status: 'completed')
      pending = create(:purchase, status: 'pending')
      
      expect(Purchase.completed).to include(completed)
      expect(Purchase.completed).not_to include(pending)
    end
  end

  describe '#total_amount' do
    it 'sums the prices of all items' do
      purchase = create(:purchase)
      create(:item, purchase: purchase, price: 10.00)
      create(:item, purchase: purchase, price: 20.00)
      
      expect(purchase.total_amount).to eq(30.00)
    end
  end
end

Controller and Request Tests — Status Codes and Responses

Request specs (or controller specs in older Rails versions) test your HTTP layer. They verify that endpoints return the correct status codes, JSON payloads, and headers. This is where you test authentication, authorization, and input validation at the integration boundary.

ruby
require 'rails_helper'

RSpec.describe 'Users API', type: :request do
  describe 'POST /api/users' do
    context 'with valid parameters' do
      it 'creates a user and returns 201' do
        post '/api/users', params: {
          user: {
            email: 'alice@example.com',
            password: 'securepass123'
          }
        }, headers: { 'Content-Type' => 'application/json' }

        expect(response).to have_http_status(:created)
        expect(response.parsed_body['user']['email']).to eq('alice@example.com')
      end
    end

    context 'with invalid parameters' do
      it 'returns 422 and error messages' do
        post '/api/users', params: {
          user: { email: 'invalid', password: 'short' }
        }, headers: { 'Content-Type' => 'application/json' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(response.parsed_body['errors']).not_to be_empty
      end
    end
  end

  describe 'GET /api/users/:id' do
    let(:user) { create(:user) }

    context 'when authenticated' do
      it 'returns the user and 200' do
        get "/api/users/#{user.id}", headers: auth_headers(user)
        
        expect(response).to have_http_status(:ok)
        expect(response.parsed_body['user']['id']).to eq(user.id)
      end
    end

    context 'when not authenticated' do
      it 'returns 401' do
        get "/api/users/#{user.id}"
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end

  describe 'PATCH /api/users/:id' do
    let(:user) { create(:user) }

    it 'updates user and returns 200' do
      patch "/api/users/#{user.id}", 
        params: { user: { email: 'newemail@example.com' } },
        headers: auth_headers(user)

      expect(response).to have_http_status(:ok)
      expect(user.reload.email).to eq('newemail@example.com')
    end
  end
end

In request tests, verify both the happy path and error conditions. Test for unauthorized access, malformed input, and edge cases like attempting to update a non-existent resource. Use helper methods like auth_headers to keep your tests DRY and maintainable. Always check the HTTP status, the response body structure, and any side effects (database changes).

System Tests — Browser Tests with Capybara

System tests simulate a real user interacting with your application through a browser. They're slower than model and request tests, so use them for critical user workflows, not every feature. A typical system test covers signup, login, and core business processes.

ruby
require 'rails_helper'

RSpec.describe 'User Registration Flow', type: :system do
  before do
    driven_by(:selenium, using: :chrome, screen_size: [1400, 1400])
  end

  scenario 'A new user signs up and activates their account' do
    visit root_path
    click_link 'Sign Up'
    
    fill_in 'Email', with: 'testuser@example.com'
    fill_in 'Password', with: 'securepass123'
    fill_in 'Password Confirmation', with: 'securepass123'
    click_button 'Create Account'

    expect(page).to have_content('Check your email to activate your account')
    
    user = User.find_by(email: 'testuser@example.com')
    expect(user.activated).to be false

    # Simulate clicking the activation link
    visit account_activation_path(user.activation_token)
    
    expect(page).to have_content('Account activated successfully')
    expect(user.reload.activated).to be true
  end

  scenario 'A user logs in and views their dashboard' do
    user = create(:user, activated: true)
    
    visit login_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: user.password
    click_button 'Log In'

    expect(page).to have_content("Welcome, #{user.name}")
    expect(page).to have_current_path(dashboard_path)
  end

  scenario 'A user views a 404 when visiting a non-existent page' do
    visit '/invalid-path'
    expect(page).to have_http_status(404)
    expect(page).to have_content('Page not found')
  end
end

System tests require a real browser driver. Rails ships with Selenium by default. Run these tests in CI/CD, but locally you might skip them to save time during development (unless you're debugging a specific flow). Keep system tests focused on user-facing behavior, not implementation details. If a user can see it and interact with it, test it at the system level.

Fixtures or Factories — Test Data with Fixtures or FactoryBot

Your tests need realistic data. You have two main approaches: Rails fixtures (YAML files) or FactoryBot (Ruby objects). FactoryBot is more flexible and is the industry standard in modern Rails projects.

Here's a FactoryBot factory setup:

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }
    password { 'securepass123' }
    name { Faker::Name.name }
    activated { true }
    activated_at { Time.current }
    subscription_expires_at { 30.days.from_now }

    trait :inactive do
      activated { false }
      activated_at { nil }
    end

    trait :subscription_expired do
      subscription_expires_at { 1.day.ago }
    end

    trait :premium do
      subscription_tier { 'premium' }
    end
  end

  factory :purchase do
    user
    status { 'pending' }
    total_price { 99.99 }
    created_at { 5.days.ago }

    trait :completed do
      status { 'completed' }
    end

    trait :recent do
      created_at { 1.hour.ago }
    end
  end

  factory :item do
    purchase
    name { Faker::Commerce.product_name }
    price { Faker::Commerce.price }
  end
end

With FactoryBot, you build test data using a consistent API. Use create to persist to the database and build to create an in-memory object without saving. Traits let you compose variations quickly: create(:user, :premium, :subscription_expired) builds a premium user whose subscription has expired.

FactoryBot with Faker generates realistic, varied test data. This catches bugs that hard-coded fixtures would miss. For example, a payment processor might reject unusually formatted email addresses; Faker explores that space.

If you prefer fixtures (lightweight and deterministic), create YAML files:

ruby
# test/fixtures/users.yml
alice:
  email: alice@example.com
  password_digest: <bcrypt hash>
  name: Alice
  activated: true
  activated_at: 2024-01-01

bob:
  email: bob@example.com
  password_digest: <bcrypt hash>
  name: Bob
  activated: false

Fixtures are fast and deterministic, but they're harder to maintain as your schema evolves. Most teams choose FactoryBot for its flexibility, though fixtures remain useful for reference data that never changes.

Common Mistakes

  • Skipping tests "for now." Test debt compounds. Write tests as you write features, even if they're simple. A test that runs every time you refactor is worth the investment.
  • Over-testing implementation details. Don't test that an instance variable is set. Test behavior: the user can log in, the purchase is marked complete, the email is sent. Implementation changes shouldn't break tests.
  • Brittle system tests. Avoid testing every button click. System tests are for critical workflows. If your UI changes, you don't want 50 tests to update. Use request tests for API coverage and system tests for the happy path.
  • Not isolating tests. Each test should be independent. Create fresh data for each test; don't rely on tests running in a specific order. Use before and after hooks to set up and tear down state.
  • Using production-like seeds in tests. Test with minimal data. If a test uses 1,000 records when 5 would suffice, it'll slow down your suite unnecessarily.
  • Mixing concerns in a single test. One test should verify one thing. If you're testing a payment flow and user notification in the same spec, split them. This makes failures easier to diagnose.

Key Takeaways

  • Layer your tests: models for logic, requests for API contracts, system tests for critical user flows. This pyramid balances speed and coverage.
  • Use FactoryBot to generate realistic, maintainable test data. Traits let you compose variations without duplication.
  • Test behavior, not implementation. A refactor shouldn't break your tests; only breaking the contract should.
  • Keep system tests focused on critical workflows. A few well-chosen system tests catch more bugs than dozens of brittle, detailed ones.
  • Run tests frequently—before committing, in CI/CD, and after refactors. Fast tests (seconds, not minutes) encourage this habit.
  • Treat your test suite as you treat your production code: refactor it, remove duplication, keep it readable. Your tests document how your system should behave.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles