rubyguides

Integration Testing in Ruby: RSpec and Capybara Guide

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.

Intro context

If unit tests tell you whether a method works, integration tests tell you whether a user journey still makes sense. That difference is why integration tests are worth the extra setup: they catch problems that only appear when routing, persistence, and rendering all happen together.

The goal is not to replace unit tests. It is to cover the seams between parts of the system. When a bug comes from two pieces interacting badly, integration tests usually find it faster than a pile of isolated assertions.

That is also why these tests are best kept focused. A good integration test checks one important flow from start to finish, then stops. If you try to check every tiny branch at the same time, the test becomes harder to read and slower to trust.

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.

That transaction boundary matters more than it might seem. It keeps your examples independent, which means a failure in one scenario does not quietly affect the next one. Good integration tests should feel repeatable, even when the app under test touches the database.

Integration tests also help you document the user experience in a way that unit tests cannot. A route, a form submission, and a rendered page become one narrative, which makes it easier to see how the app behaves from the outside.

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:

The transaction boundary that wraps each feature spec matters more than it might seem at first. It keeps your examples independent, which means a failure in one scenario does not quietly affect the next one. Good integration tests should feel repeatable, even when the app under test touches the database, because a clean slate after each test eliminates ordering-dependent bugs.

# 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, which is essential for CI pipelines where no display server is available. For JavaScript-heavy features, switch to selenium_chrome for a visible browser during local debugging. Capybara also supports configurable wait times, which help with asynchronous page updates that may not render immediately after a click event.

Testing database interactions

Integration tests exercise the full database layer from the controller through to the rendered response. Use the have_css matcher to verify that persisted data actually appears on the page as the user would see it rather than just checking a database count:

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.

When you write tests like this, focus on the visible result rather than the implementation details. That keeps the test useful even if you later refactor how the post gets saved.

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.

That focus also makes failures easier to read. If the test says the response should be unauthorized and the app returns something else, you can usually tell quickly whether the problem is routing, auth, or the controller action itself.

When you test APIs at this level, you are usually checking a contract more than a single method. That contract includes status codes, payload shape, and the rules around authentication, so keeping the assertion set small and focused helps the test stay useful.

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.

This pattern is especially helpful when the production code would otherwise depend on slow or unreliable services. A stubbed HTTP call lets you verify both the success path and the failure path without making your test suite wait on the network.

This pattern is especially helpful when the production code would otherwise depend on slow or unreliable services. A stubbed HTTP call lets you verify both the success path and the failure path without making your test suite wait on the network.

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.

Email tests are often best treated as integration checks rather than unit checks. You want to know that the full flow worked, not that a single helper method formatted the address correctly. That keeps the test centered on the user-visible result.

Email tests are often best treated as integration checks rather than unit checks. You want to know that the full flow worked, not that a single helper method formatted the address correctly. That keeps the test centered on the user-visible result.

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.

The best integration tests are boring in the right way. They cover the important paths, fail for understandable reasons, and stay stable enough that your team trusts them instead of working around them.

If you want to compare these examples with a lower-level testing style, continue with Mocking and Stubbing with Minitest. It shows how to isolate behavior when a full workflow test is more than you need.

See Also