RSpec Testing for Rails
Overview
Write comprehensive, maintainable RSpec tests following industry best practices. This skill combines guidance from Better Specs and thoughtbot's testing guides to produce high-quality test coverage for Rails applications.
Core Testing Principles
1. Test-Driven Development (TDD)
Follow the Red-Green-Refactor cycle:
- Red: Write failing tests that define expected behavior
- Green: Implement minimal code to make tests pass
- Refactor: Improve code while tests continue to pass
2. Test Structure (Arrange-Act-Assert)
Organize tests with clear phases separated by newlines:
it 'creates a new article' do
# Arrange - set up test data
user = create(:user)
attributes = {title: 'Test Article', body: 'Content here'}
# Act - perform the action
article = Article.create(attributes)
# Assert - verify the outcome
expect(article).to be_persisted
expect(article.title).to eq('Test Article')
end3. Single Responsibility
Each test should verify one behavior. For unit tests, use one expectation per test. For integration tests, multiple expectations are acceptable when testing a complete flow.
4. Test Real Behavior
Avoid over-mocking. Test actual application behavior when possible. Only stub external services, slow operations, and dependencies outside your control.
Test Type Decision Tree
When to Write Model Specs
Use model specs (spec/models/) for:
- Validations
- Associations
- Scopes
- Instance methods
- Class methods
- Enums and constants
- Database constraints
Example:
# spec/models/article_spec.rb
RSpec.describe Article do
describe 'validations' do
it 'validates presence of title' do
article = build(:article, title: nil)
expect(article).not_to be_valid
expect(article.errors[:title]).to include("can't be blank")
end
end
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:comments) }
end
describe '#published?' do
it 'returns true when status is published' do
article = build(:article, status: :published)
expect(article.published?).to be true
end
end
endWhen to Write Controller Specs
Use controller specs (spec/controllers/) for:
- Authorization checks (Pundit/CanCanCan)
- Request routing and parameter handling
- Response status codes
- Instance variable assignments
- Flash messages
- Redirects
Example:
# spec/controllers/articles_controller_spec.rb
RSpec.describe ArticlesController do
describe 'POST #create' do
context 'with valid parameters' do
it 'creates a new article and redirects' do
user = create(:user)
session[:user_id] = user.id
valid_attributes = {
title: 'Test Article',
body: 'Article content'
}
expect do
post :create, params: {article: valid_attributes}
end.to change(Article, :count).by(1)
expect(response).to redirect_to(Article.last)
end
end
context 'with invalid parameters' do
it 'does not create article and renders new template' do
user = create(:user)
session[:user_id] = user.id
invalid_attributes = {title: '', body: ''}
expect do
post :create, params: {article: invalid_attributes}
end.not_to change(Article, :count)
expect(response).to render_template(:new)
end
end
end
endWhen to Write System Specs
Use system specs (spec/system/) for:
- End-to-end user workflows
- Multi-step interactions
- JavaScript functionality
- Form submissions
- Navigation flows
- Real user scenarios
Naming convention: user_action_spec.rb or feature_description_spec.rb
Example:
# spec/system/article_creation_spec.rb
RSpec.describe 'Article Creation' do
it 'allows a user to create a new article' do
user = create(:user)
# Sign in
visit '/login'
fill_in 'Email', with: user.email
fill_in 'Password', with: 'password'
click_button 'Sign In'
# Navigate to new article page
click_link 'New Article'
expect(page).to have_current_path(new_article_path)
# Fill out the article form
fill_in 'Title', with: 'My Test Article'
fill_in 'Body', with: 'This is the article content'
select 'Published', from: 'Status'
# Submit the form
click_button 'Create Article'
expect(page).to have_content('Article created successfully!')
expect(page).to have_content('My Test Article')
end
endWhen to Write Component Specs
Use component specs (spec/components/) for:
- ViewComponent rendering
- Variant behavior
- Slot functionality
- Conditional rendering
- Component attributes
Example:
# spec/components/button_component_spec.rb
RSpec.describe ButtonComponent, type: :component do
describe 'variants' do
it 'renders primary variant' do
render_inline(described_class.new(variant: :primary)) { 'Click me' }
button = page.find('button')
expect(button[:class]).to include('btn-primary')
expect(page).to have_button('Click me')
end
it 'renders secondary variant' do
render_inline(described_class.new(variant: :secondary)) { 'Cancel' }
button = page.find('button')
expect(button[:class]).to include('btn-secondary')
end
end
endWhen to Write Service/Integration Specs
Use service/integration specs (spec/services/, spec/integration/) for:
- Complex business logic
- Multi-step workflows
- External API integrations
- Background job processing
- Data transformations
RSpec Syntax & Style Guide
Describe Blocks
Use Ruby documentation conventions:
.method_namefor class methods#method_namefor instance methods
describe '.find_by_title' do # class method
describe '#publish' do # instance method
describe 'validations' do # groupingContext Blocks
Start with "when," "with," or "without":
context 'when user is admin' do
context 'with valid parameters' do
context 'without authentication' doIt Blocks
- Keep descriptions under 40 characters
- Use third-person present tense
- Never use "should" in descriptions
# ✅ Good
it 'creates a new article' do
it 'validates presence of title' do
it 'redirects to dashboard' do
# ❌ Bad
it 'should create a new article' do
it 'should validate presence of title' doExpectations
Always use expect syntax (never should):
# ✅ Good
expect(article).to be_valid
expect(response).to have_http_status(:success)
expect { action }.to change(Article, :count).by(1)
# ❌ Bad (deprecated)
article.should be_valid
response.should have_http_status(:success)One-Liners
Use is_expected for concise one-line specs:
subject { article }
it { is_expected.to be_valid }
it { is_expected.to be_persisted }System Test Best Practices
Authentication in System Tests
Test authentication flows directly without stubbing:
# Good - test the actual login flow
visit '/login'
fill_in 'Email', with: user.email
fill_in 'Password', with: 'password'
click_button 'Sign In'
expect(page).to have_content('Dashboard')Controller Test Authentication
For controller tests, use direct session assignment rather than stubbing:
# ✅ Good - direct session assignment
session[:user_id] = user.id
# ❌ Avoid - stubbing authentication
allow_any_instance_of(Controller).to receive(:logged_in?).and_return(true)Avoid CSS Class Testing
Don't test implementation details like CSS utility classes. Test semantic selectors and content:
# ✅ Good - semantic selectors
expect(page).to have_selector(:test_id, 'user-modal')
expect(page).to have_css("[aria-hidden='false']")
expect(page).to have_content('Success message')
expect(page).to have_button('Submit')
# ❌ Bad - coupling to CSS implementation
expect(page).to have_css('.opacity-100')
expect(page).to have_css('.bg-red-500')
expect(page).to have_css('.rounded-lg')Factory Patterns
Organization
- Associations (implicit) first
- Attributes (alphabetical)
- Traits (alphabetical)
FactoryBot.define do
factory :article do
# Associations
user
category
# Attributes (alphabetical)
body { 'Article content goes here...' }
published_at { Time.current }
status { :draft }
title { 'Sample Article Title' }
# Traits (alphabetical)
trait :published do
status { :published }
published_at { 1.day.ago }
end
trait :with_tags do
after(:create) do |article|
create_list(:tag, 3, article: article)
end
end
end
endPrefer Build Over Create
Use build and build_stubbed when database persistence isn't needed:
# ✅ Good - fast, no database hit
it 'validates title format' do
article = build(:article, title: '')
expect(article).not_to be_valid
end
# Less optimal - unnecessary database hit
it 'validates title format' do
article = create(:article, title: '')
expect(article).not_to be_valid
endCommon Testing Patterns
Testing Validations
describe 'validations' do
it 'validates presence of title' do
article = build(:article, title: nil)
expect(article).not_to be_valid
expect(article.errors[:title]).to include("can't be blank")
end
it 'validates length of title' do
article = build(:article, title: 'a' * 256)
expect(article).not_to be_valid
end
it 'allows valid titles' do
article = build(:article, title: 'Valid Title')
expect(article).to be_valid
end
endTesting Enums
describe 'enums' do
it 'defines status enum' do
expect(described_class.statuses).to eq({
'draft' => 'draft',
'published' => 'published',
'archived' => 'archived'
})
end
it 'has correct default' do
article = described_class.new
expect(article.status).to eq('draft')
end
endTesting Authorization
context 'when user is not admin' do
it 'raises authorization error' do
user = create(:user, role: :member)
session[:user_id] = user.id
expect do
get :admin_dashboard
end.to raise_error(Pundit::NotAuthorizedError)
end
endUsing Shoulda Matchers
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:comments) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(255) }
endWhat to Avoid
❌ Don't Stub the System Under Test
Never mock or stub methods on the class being tested:
# ❌ Bad
it 'processes payment' do
order = Order.new
allow(order).to receive(:calculate_total).and_return(100)
expect(order.process_payment).to be true
end
# ✅ Good
it 'processes payment' do
order = Order.new(line_items: [line_item])
expect(order.process_payment).to be true
end❌ Don't Test Private Methods
Test the public interface. Private methods are tested indirectly:
# ❌ Bad
describe '#calculate_total (private)' do
it 'sums line items' do
order.send(:calculate_total)
end
end
# ✅ Good
describe '#total' do
it 'returns sum of line items' do
expect(order.total).to eq(100)
end
end❌ Avoid any_instance_of
Use dependency injection instead:
# ❌ Bad
allow_any_instance_of(PaymentService).to receive(:charge)
# ✅ Good
payment_service = instance_double(PaymentService)
allow(payment_service).to receive(:charge).and_return(success)
order = Order.new(payment_service: payment_service)Quick Reference
Test Organization
RSpec.describe ClassName do
# Setup (let, before)
let(:resource) { create(:resource) }
before do
# common setup
end
# Validations
describe 'validations' do
end
# Associations
describe 'associations' do
end
# Class methods
describe '.class_method' do
end
# Instance methods
describe '#instance_method' do
context 'when condition' do
it 'does something' do
end
end
end
endExpectation Matchers
# Equality
expect(value).to eq(expected)
expect(value).to be(expected) # same object
expect(value).to match(/regex/)
# Predicates
expect(object).to be_valid
expect(object).to be_persisted
expect(collection).to be_empty
# Collections
expect(array).to include(item)
expect(array).to contain_exactly(1, 2, 3)
expect(hash).to have_key(:name)
# Changes
expect { action }.to change(Model, :count).by(1)
expect { action }.to change { object.attribute }.from(old).to(new)
# Errors
expect { action }.to raise_error(ErrorClass)
expect { action }.not_to raise_errorResources
This skill includes detailed reference documentation in the references/ directory:
references/better_specs_guide.md
Comprehensive patterns from Better Specs including:
- Describe/context/it block conventions
- Subject and let usage
- Mocking strategies
- Shared examples
- Factory patterns
references/thoughtbot_patterns.md
thoughtbot's RSpec best practices covering:
- Modern RSpec syntax
- Test structure and organization
- What to avoid in tests
- Capybara patterns for system tests
- Factory organization
Load these references when you need detailed examples or are unsure about a specific pattern.