Integration Testing in Ruby
Integration tests verify that multiple components of your application work together correctly. Unlike unit tests that isolate individual methods, integration tests exercise entire workflows—from controller actions to database changes to rendered views.
Why Integration Tests Matter
Unit tests give you confidence that individual pieces work. Integration tests prove they fit together. A perfectly tested model can still fail when it meets a controller that does not handle its errors correctly.
Integration tests catch problems that unit tests miss:
- Incorrect routing or URL generation
- Missing database transactions
- View rendering issues
- Session and cookie handling
- Authentication and authorization flows
- JSON API response structure
- Email delivery and content
The tradeoff is speed—integration tests are slower than unit tests because they exercise more of the stack. Balance both in your test suite. Use unit tests for logic and edge cases, integration tests for workflows.
Integration Testing with RSpec
RSpec Rails provides integration testing through feature specs. These simulate browser interactions:
# spec/features/user_registration_spec.rb
require 'rails_helper'
RSpec.describe 'User Registration', type: :feature do
scenario 'user successfully registers' do
visit new_user_registration_path
fill_in 'Email', with: 'new@example.com'
fill_in 'Password', with: 'secure123'
fill_in 'Password confirmation', with: 'secure123'
click_button 'Sign up'
expect(page).to have_content('Welcome! You have signed up successfully.')
expect(User.last.email).to eq('new@example.com')
end
scenario 'registration fails with invalid email' do
visit new_user_registration_path
fill_in 'Email', with: 'invalid-email'
fill_in 'Password', with: 'secure123'
fill_in 'Password confirmation', with: 'secure123'
click_button 'Sign up'
expect(page).to have_content('Email is invalid')
expect(User.count).to eq(0)
end
end
The type: :feature tag tells RSpec to use Capybara for browser simulation. Each scenario runs in a transaction that is rolled back after the test, keeping the database clean.
Setting Up Capybara
Capybara drives browsers for feature tests. Install it alongside RSpec:
# Gemfile
group :test do
gem 'capybara'
gem 'selenium-webdriver'
gem 'webdrivers'
end
Configure it in your Rails helper:
# spec/rails_helper.rb
require 'capybara/rails'
require 'capybara/rspec'
Capybara.default_driver = :selenium_chrome_headless
Capybara.default_max_wait_time = 5
The headless Chrome driver runs tests without opening a visible browser—essential for CI pipelines. For JavaScript-heavy features, switch to Capybara.javascript_driver = :selenium_chrome.
Testing Database Interactions
Integration tests exercise the full database layer. Use the have_css matcher to verify data persisted:
RSpec.describe 'Creating a Post', type: :feature do
let(:user) { User.create!(email: 'author@example.com', password: 'password') }
scenario 'user creates and sees their post' do
login_as(user)
visit new_post_path
fill_in 'Title', with: 'My First Post'
fill_in 'Body', with: 'This is the content.'
click_button 'Create Post'
expect(page).to have_css('.post-title', text: 'My First Post')
expect(Post.last.title).to eq('My First Post')
end
end
The login_as helper (from devise or clearance) signs in a user for the test. Without it, authentication would block the request.
Testing JSON APIs
Modern Rails apps often expose JSON APIs. Test them with json: true:
RSpec.describe 'API Posts Controller', type: :request do
let(:user) { User.create!(email: 'api@example.com', password: 'password') }
describe 'GET /api/posts' do
scenario 'returns all posts' do
Post.create!(title: 'First', user: user)
Post.create!(title: 'Second', user: user)
get '/api/posts'
expect(response).to have_http_status(:ok)
expect(json_body.size).to eq(2)
end
scenario 'requires authentication' do
get '/api/posts'
expect(response).to have_http_status(:unauthorized)
end
end
end
The json_body helper parses the response. This pattern works well for testing REST APIs.
Testing HTTP Requests
For API-heavy applications, test external HTTP calls with WebMock or VCR:
require 'webmock/rspec'
RSpec.describe 'Fetching Weather Data', type: :feature do
scenario 'displays weather for valid city' do
stub_request(:get, /api.weather.com/)
.to_return(json: { temp: 72, condition: 'sunny' })
visit weather_path(city: 'London')
expect(page).to have_content('72F')
expect(page).to have_content('sunny')
end
scenario 'handles API error gracefully' do
stub_request(:get, /api.weather.com/)
.to_raise(Net::ReadTimeout)
visit weather_path(city: 'London')
expect(page).to have_content('Unable to fetch weather data')
end
end
WebMock intercepts HTTP requests, letting you test error handling without depending on external services.
Testing Email Delivery
Rails integration tests can verify emails are sent:
RSpec.describe 'Password Reset', type: :feature do
scenario 'sends reset email' do
user = User.create!(email: 'reset@example.com', password: 'old')
visit new_password_reset_path
fill_in 'Email', with: 'reset@example.com'
click_button 'Send Reset Instructions'
expect(ActionMailer::Base.deliveries.size).to eq(1)
email = ActionMailer::Base.deliveries.last
expect(email.to).to include('reset@example.com')
expect(email.subject).to include('Password Reset')
end
end
This works with ActionMailer in Rails. For more complex scenarios, use the email_spec gem.
Best Practices for Integration Tests
Keep integration tests focused and maintainable:
Test critical paths only. Not every button click needs an integration test. Focus on user journeys that matter—signup, checkout, search, payment flows.
Keep tests independent. Each test should work in isolation. Do not rely on data created in previous tests. Use let blocks and factories.
Use factories consistently. FactoryBot creates test data reliably. Define traits for common scenarios.
Assert meaningful outcomes. Check what the user sees, not internal state. expect(page).to have_content over expect(user.posts.count).to eq(1).
Clean up after yourself. The database cleaner handles this, but verify tests do not leak state between runs.
Run them in CI but not every save. Integration tests are slower. Run them on CI but use guard or overcommit for local development.
See Also
ruby-minitest-basics— Unit testing fundamentals with Minitestruby-mocking-with-minitest— Mocking and stubbing patternsruby-testing-rspec— RSpec syntax and matchers referenceenumerable-reduce— Using reduce for data aggregation