Skip to content

Instantly share code, notes, and snippets.

@aviflombaum
Created July 24, 2025 14:02
Show Gist options
  • Save aviflombaum/613a4dd89c18a5c28604d74d972d2322 to your computer and use it in GitHub Desktop.
Save aviflombaum/613a4dd89c18a5c28604d74d972d2322 to your computer and use it in GitHub Desktop.

RSpec Best Practices for Ruby on Rails Testing

Core Testing Philosophy

Three Fundamental Beliefs

  1. Tests should be reliable - They should pass when code works and fail when it doesn't
  2. Tests should be easy to write - Simple syntax and clear patterns
  3. Tests should be easy to understand - Readable by you and your team, now and in the future

General Best Practices

1. Write Explicit, Active Expectations

Each test should clearly state what it's testing using active voice:

# Good - uses active voice
it "requires a nickname" do
  user = User.new(nickname: nil)
  expect(user).to be_invalid
  expect(user.errors[:nickname]).to include("can't be blank")
end

# Avoid - uses passive voice
it "should have a nickname"

2. One Expectation Per Test (for Unit Tests)

Keep unit tests focused on a single behavior:

# Good - separate tests for each validation
it "requires a nickname" do
  user = FactoryBot.build(:user, nickname: nil)
  expect(user).to be_invalid
  expect(user.errors[:nickname]).to include("can't be blank")
end

it "requires an email" do
  user = FactoryBot.build(:user, email: nil)
  expect(user).to be_invalid
  expect(user.errors[:email]).to include("can't be blank")
end

3. Test Both Happy and Unhappy Paths

Always test what should happen AND what shouldn't happen:

describe "scope by_word_in_name" do
  context "when a match is found" do
    it "returns matching recipes" do
      recipe = FactoryBot.create(:recipe, name: "Pepperoni Pizza")
      results = Recipe.by_word_in_name("pepperoni")
      
      expect(results).to include(recipe)
    end
  end

  context "when no match is found" do
    it "returns an empty collection" do
      FactoryBot.create(:recipe, name: "Cheese Pizza")
      results = Recipe.by_word_in_name("veggie")
      
      expect(results).to be_empty
    end
  end
end

Model Testing Best Practices

Test Structure

Model specs should test:

  • Validations
  • Instance methods
  • Class methods and scopes
  • Edge cases
RSpec.describe User, type: :model do
  # Test basic validity
  it "is valid with an email, password, and nickname" do
    user = FactoryBot.build(:user)
    expect(user).to be_valid
  end

  # Test validations
  it "requires a nickname" do
    user = FactoryBot.build(:user, nickname: nil)
    expect(user).to be_invalid
    expect(user.errors[:nickname]).to include("can't be blank")
  end

  # Test instance methods
  describe "#new_to_site?" do
    it "returns true for users created within the last month" do
      user = FactoryBot.build(:user, created_at: Time.now)
      expect(user).to be_new_to_site
    end

    it "returns false for users created more than a month ago" do
      user = FactoryBot.build(:user, created_at: 2.months.ago)
      expect(user).to_not be_new_to_site
    end
  end
end

Controller/Request Spec Best Practices

Use Request Specs Over Controller Specs

Rails and RSpec recommend request specs for testing controllers:

RSpec.describe "Recipes", type: :request do
  describe "GET /index" do
    context "as a guest" do
      it "redirects to sign-in" do
        get recipes_path
        expect(response).to redirect_to sign_in_path
      end
    end

    context "as an authenticated user" do
      it "displays recipes list" do
        user = FactoryBot.create(:user)
        get recipes_path(as: user)
        
        expect(response).to be_successful
      end
    end
  end
end

Test Different User States

Always test guest, authenticated, and authorized states:

describe "GET /edit" do
  context "as a guest" do
    it "redirects to sign-in" do
      recipe = FactoryBot.create(:recipe)
      get edit_recipe_path(recipe)
      expect(response).to redirect_to sign_in_path
    end
  end

  context "as the recipe owner" do
    it "displays edit form" do
      user = FactoryBot.create(:user)
      recipe = FactoryBot.create(:recipe, user: user)
      
      get edit_recipe_path(recipe, as: user)
      expect(response).to be_successful
    end
  end

  context "as another user" do
    it "returns 404" do
      user = FactoryBot.create(:user)
      recipe = FactoryBot.create(:recipe)
      
      get edit_recipe_path(recipe, as: user)
      expect(response).to have_http_status(:not_found)
    end
  end
end

System/Feature Spec Best Practices

Keep System Specs High-Level

Focus on user workflows, not implementation details:

RSpec.describe "User login", type: :system do
  before do
    driven_by(:rack_test)  # Use Selenium only when needed
  end

  it "allows user to sign in and out" do
    user = FactoryBot.create(:user, 
      email: "[email protected]",
      password: "password123"
    )

    visit root_path
    click_link "Sign in"
    
    fill_in "Email", with: "[email protected]"
    fill_in "Password", with: "password123"
    click_button "Sign in"
    
    expect(page).to have_content user.nickname
    expect(page).to have_button "Sign out"
    
    click_button "Sign out"
    expect(page).to have_link "Sign in"
  end
end

Use JavaScript Driver Only When Necessary

# For JavaScript interactions
before do
  driven_by(:selenium_chrome_headless)
end

Factory Best Practices

Use Sequences for Unique Values

FactoryBot.define do
  factory :user do
    sequence(:nickname) { |n| "test_user#{n}" }
    sequence(:email) { |n| "test_user#{n}@example.com" }
    password { "password" }
  end
end

Use Traits for Different States

FactoryBot.define do
  factory :recipe do
    name { "Delicious Recipe" }
    association :category
    association :user
    
    trait :invalid do
      name { nil }
    end
    
    trait :with_comments do
      after(:create) do |recipe|
        create_list(:comment, 3, recipe: recipe)
      end
    end
  end
end

# Usage
FactoryBot.create(:recipe, :invalid)
FactoryBot.create(:recipe, :with_comments)

Minimize Database Hits

  • Use build instead of create when possible
  • Be mindful of associations creating extra records

DRY Testing Techniques

Use let for Lazy-Loading Test Data

RSpec.describe Comment, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:recipe) { FactoryBot.create(:recipe) }
  
  it "is valid with all attributes" do
    comment = Comment.new(
      user: user,
      recipe: recipe,
      comment: "Great recipe!"
    )
    expect(comment).to be_valid
  end
end

Extract Common Behavior to Support Modules

# spec/support/login_support.rb
module LoginSupport
  def sign_in_as(user)
    visit sign_in_path
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Sign in"
  end
end

RSpec.configure do |config|
  config.include LoginSupport
end

Use Shared Contexts for Common Setup

# spec/support/contexts/recipe_setup.rb
RSpec.shared_context "recipe setup" do
  let(:user) { FactoryBot.create(:user) }
  let(:recipe) { FactoryBot.create(:recipe, user: user) }
  let(:category) { FactoryBot.create(:category) }
end

# Usage in specs
include_context "recipe setup"

Organization Best Practices

Use describe and context Blocks

  • describe for general functionality
  • context for specific states
RSpec.describe Recipe, type: :model do
  describe "#by_word_in_name" do
    context "when matches exist" do
      # tests for matching scenarios
    end
    
    context "when no matches exist" do
      # tests for non-matching scenarios
    end
  end
end

Keep Test Structure No Deeper Than 3 Levels

Avoid overly complex nesting that makes tests hard to follow.

Testing Best Practices Summary

Do:

  • ✅ Test edge cases and validations
  • ✅ Use factories to simplify test data creation
  • ✅ Keep tests focused and readable
  • ✅ Test at the appropriate level (unit vs integration)
  • ✅ Use before blocks for common setup
  • ✅ Aggregate failures in integration tests when appropriate

Don't:

  • ❌ Over-mock (especially Active Record methods)
  • ❌ Create overly DRY tests that sacrifice readability
  • ❌ Test private methods directly
  • ❌ Skip writing tests for "simple" functionality
  • ❌ Use fixtures instead of factories

Running Tests Efficiently

# Run all tests
bin/rspec

# Run specific file
bin/rspec spec/models/user_spec.rb

# Run specific test by line number
bin/rspec spec/models/user_spec.rb:42

# Run only model specs
bin/rspec spec/models

# Run only request specs
bin/rspec spec/requests

# Run only feature/system specs
bin/rspec spec/features
bin/rspec spec/system

# Run specific types of specs
bin/rspec spec/controllers spec/models
bin/rspec --tag type:model
bin/rspec --tag type:request

# Skip specific types of specs
bin/rspec --exclude-pattern "spec/{features,system}/**/*_spec.rb"
bin/rspec --tag ~type:system
bin/rspec --tag ~slow

# Run only specs marked with specific tags
bin/rspec --tag focus
bin/rspec --tag integration

# Run with documentation format
bin/rspec --format documentation

Key Takeaways

  1. Start with the basics - Don't try to test everything perfectly from the start
  2. Tests are documentation - Write them to be understood by future developers
  3. Balance DRY with readability - Sometimes duplication in tests is acceptable
  4. Test behavior, not implementation - Focus on what the code does, not how
  5. Use the testing pyramid - More unit tests, fewer integration tests
  6. Practice Red-Green-Refactor - Write failing tests first, make them pass, then improve
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment