Overview
Integration testing validates the interaction between multiple components of a system, ensuring they work together correctly. Unlike unit tests that isolate individual classes or methods, integration tests verify that separate modules, services, databases, external APIs, and subsystems communicate and function as expected when combined.
Integration tests operate at a higher level than unit tests but remain more focused than end-to-end tests. They typically test specific integration points: database interactions, API endpoints, service boundaries, or multi-component workflows. The goal is to catch errors that emerge from component interaction rather than component logic itself.
In Ruby applications, integration testing commonly targets Rails controllers with database operations, background job processing with external services, API clients communicating with third-party services, or multi-step business processes spanning several models and services. A typical integration test might verify that creating an order record triggers an email notification, updates inventory counts, and logs the transaction.
Integration tests differ from unit tests in scope and dependencies. Unit tests mock external dependencies and test code in isolation. Integration tests use real or realistic dependencies, trading execution speed for confidence in system behavior. A unit test for an order service might stub the email sender and database; an integration test would use a test database and verify the email was queued.
The testing pyramid places integration tests in the middle layer. Applications typically have many fast unit tests, fewer integration tests covering critical paths, and minimal slow end-to-end tests. This balance provides confidence in system correctness without excessive test suite runtime.
Key Principles
Integration testing focuses on verifying contracts between components. When two modules interact, they establish an implicit or explicit contract about inputs, outputs, side effects, and error conditions. Integration tests validate these contracts hold under realistic conditions.
Test isolation remains important even with shared dependencies. Each integration test should set up its required state, execute operations, verify outcomes, and clean up afterward. Tests that leak state create intermittent failures and maintenance burden. Database transactions or cleanup strategies ensure tests remain independent.
The scope of an integration test determines what qualifies as "real" versus mocked. Testing a controller action that calls a service, updates the database, and enqueues a job might use real database connections and job queues but mock external API calls. The decision depends on what integration point the test targets. Testing the API client integration would reverse these choices.
Test data management becomes more complex in integration tests. Unlike unit tests with simple fixtures, integration tests often require related records across multiple tables. Factory libraries help create realistic test data graphs. However, excessive setup indicates tests covering too much scope.
Integration tests expose timing and ordering issues absent in unit tests. Database transactions, background jobs, asynchronous operations, and caching introduce timing dependencies. Tests must account for these realities without introducing brittle sleep statements or arbitrary timeouts.
External service interactions require careful handling. Directly calling third-party APIs in tests creates fragility and slowness. Recording HTTP interactions, using test-specific endpoints, or running local service stubs provides deterministic behavior. The integration test verifies the application correctly formats requests and handles responses, not that the external service works.
Database state management differs significantly from unit testing. Integration tests need database records to exist, associations to be valid, and constraints to be satisfied. Using database transactions to roll back changes after each test provides isolation. Alternatively, truncation strategies clear tables between tests. Each approach has trade-offs in speed and compatibility with certain test scenarios.
Test organization often mirrors application structure. Controller tests verify HTTP request handling, model tests with database interactions validate persistence logic, and service tests check business process coordination. This organization helps developers locate relevant tests and understand what each test suite covers.
Ruby Implementation
Ruby provides several frameworks and tools for integration testing. RSpec and Minitest both support integration testing patterns, with slight differences in syntax and organization. Rails adds framework-specific helpers and conventions for testing integrated components.
RSpec integration tests often use request specs for HTTP interactions:
# spec/requests/orders_spec.rb
RSpec.describe "Orders", type: :request do
describe "POST /orders" do
it "creates order and sends confirmation email" do
product = create(:product, price: 100)
expect {
post "/orders", params: {
order: {
product_id: product.id,
quantity: 2
}
}
}.to change(Order, :count).by(1)
.and change(ActionMailer::Base.deliveries, :count).by(1)
expect(response).to have_http_status(:created)
order = Order.last
expect(order.total).to eq(200)
email = ActionMailer::Base.deliveries.last
expect(email.to).to include(order.customer_email)
expect(email.subject).to match(/Order Confirmation/)
end
end
end
Minitest integration tests use similar patterns with different syntax:
# test/integration/order_creation_test.rb
class OrderCreationTest < ActionDispatch::IntegrationTest
test "creating order processes payment and updates inventory" do
product = products(:widget)
initial_stock = product.stock_count
post orders_path, params: {
order: {
product_id: product.id,
quantity: 3,
payment_token: "tok_visa"
}
}
assert_response :created
assert_equal 1, Order.count
order = Order.last
assert_equal "completed", order.status
assert order.payment_processed?
product.reload
assert_equal initial_stock - 3, product.stock_count
end
end
Database Cleaner or Rails transactional fixtures handle test isolation. Transactional tests wrap each test in a database transaction and roll back afterward:
# spec/rails_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
end
For tests involving background jobs or JavaScript, truncation strategies work better:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do |example|
DatabaseCleaner.strategy = example.metadata[:js] ? :truncation : :transaction
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Factory Bot creates test data with proper associations:
# spec/factories/orders.rb
FactoryBot.define do
factory :order do
association :customer
association :product
quantity { 1 }
trait :with_shipping do
after(:create) do |order|
create(:shipping_address, order: order)
end
end
trait :processed do
status { :completed }
processed_at { Time.current }
end
end
end
WebMock or VCR handles external HTTP requests. WebMock stubs requests:
# spec/requests/weather_api_spec.rb
RSpec.describe "Weather API integration" do
it "fetches and caches weather data" do
stub_request(:get, "https://api.weather.com/forecast/chicago")
.to_return(
status: 200,
body: { temperature: 72, conditions: "sunny" }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
get "/weather/chicago"
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["temperature"]).to eq(72)
# Verify caching
expect(WebMock).to have_requested(:get, "https://api.weather.com/forecast/chicago").once
get "/weather/chicago"
expect(WebMock).to have_requested(:get, "https://api.weather.com/forecast/chicago").once
end
end
VCR records real HTTP interactions and replays them:
# spec/support/vcr.rb
VCR.configure do |config|
config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
config.hook_into :webmock
config.configure_rspec_metadata!
config.filter_sensitive_data('<API_KEY>') { ENV['STRIPE_API_KEY'] }
end
# spec/requests/payment_processing_spec.rb
RSpec.describe "Payment processing", :vcr do
it "charges customer card" do
order = create(:order, total: 5000)
post "/orders/#{order.id}/charge", params: {
token: "tok_visa"
}
expect(response).to have_http_status(:ok)
expect(order.reload.payment_status).to eq("charged")
end
end
Background job testing requires special configuration. ActiveJob::TestHelper provides helpers:
# spec/requests/report_generation_spec.rb
RSpec.describe "Report generation" do
include ActiveJob::TestHelper
it "enqueues report job and processes asynchronously" do
expect {
post "/reports", params: { type: "sales", period: "monthly" }
}.to have_enqueued_job(GenerateReportJob)
.with(hash_including(type: "sales"))
.on_queue("reports")
perform_enqueued_jobs
expect(Report.last.status).to eq("completed")
expect(Report.last.file).to be_present
end
end
Practical Examples
Testing a multi-step checkout process demonstrates integration testing scope. This example tests cart update, inventory validation, payment processing, and order creation:
# spec/requests/checkout_flow_spec.rb
RSpec.describe "Checkout flow" do
it "completes purchase with inventory and payment validation" do
product = create(:product, stock_count: 10, price: 50_00)
user = create(:user)
# Add to cart
post "/cart/items", params: {
product_id: product.id,
quantity: 2
}, headers: authenticated_headers(user)
expect(response).to have_http_status(:created)
# Initiate checkout
post "/checkout", headers: authenticated_headers(user)
expect(response).to have_http_status(:ok)
checkout = JSON.parse(response.body)
expect(checkout["total"]).to eq(10_000)
# Process payment
stub_request(:post, "https://api.stripe.com/v1/charges")
.to_return(status: 200, body: { id: "ch_123", status: "succeeded" }.to_json)
post "/checkout/complete", params: {
payment_method: "tok_visa",
shipping_address: {
street: "123 Main St",
city: "Chicago",
state: "IL",
zip: "60601"
}
}, headers: authenticated_headers(user)
expect(response).to have_http_status(:created)
# Verify order created
order = Order.last
expect(order.user).to eq(user)
expect(order.status).to eq("pending_fulfillment")
expect(order.total).to eq(10_000)
# Verify inventory updated
expect(product.reload.stock_count).to eq(8)
# Verify payment recorded
expect(order.payment.external_id).to eq("ch_123")
expect(order.payment.status).to eq("succeeded")
end
it "prevents checkout when inventory insufficient" do
product = create(:product, stock_count: 1, price: 50_00)
user = create(:user)
post "/cart/items", params: {
product_id: product.id,
quantity: 5
}, headers: authenticated_headers(user)
post "/checkout", headers: authenticated_headers(user)
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["error"]).to match(/insufficient stock/i)
end
end
Service integration testing verifies external API client behavior. This example tests a shipping rate calculator that calls an external API:
# spec/services/shipping_calculator_spec.rb
RSpec.describe ShippingCalculator do
describe "#calculate_rate" do
it "fetches rates from carrier API and caches results" do
shipment = build(:shipment,
origin_zip: "60601",
destination_zip: "10001",
weight_oz: 16
)
stub_request(:post, "https://api.shippo.com/shipments")
.with(body: hash_including(
address_from: hash_including(zip: "60601"),
address_to: hash_including(zip: "10001"),
parcels: array_including(hash_including(weight: "16"))
))
.to_return(status: 200, body: {
rates: [
{ provider: "USPS", service_level: "Priority", amount: "8.50" },
{ provider: "USPS", service_level: "Ground", amount: "6.25" }
]
}.to_json)
calculator = ShippingCalculator.new(shipment)
rates = calculator.calculate_rate
expect(rates).to include(
hash_including(provider: "USPS", service_level: "Priority", cents: 850)
)
# Verify caching
Rails.cache.clear
Rails.cache.write("shipping_rate:#{shipment.cache_key}", rates)
cached_calculator = ShippingCalculator.new(shipment)
cached_rates = cached_calculator.calculate_rate
expect(cached_rates).to eq(rates)
expect(WebMock).to have_requested(:post, "https://api.shippo.com/shipments").once
end
it "handles API errors gracefully" do
shipment = build(:shipment)
stub_request(:post, "https://api.shippo.com/shipments")
.to_return(status: 503, body: "Service Unavailable")
calculator = ShippingCalculator.new(shipment)
expect {
calculator.calculate_rate
}.to raise_error(ShippingCalculator::ServiceUnavailable)
end
end
end
Testing background job integration with external services:
# spec/jobs/invoice_generation_job_spec.rb
RSpec.describe InvoiceGenerationJob do
include ActiveJob::TestHelper
it "generates PDF invoice and uploads to storage" do
order = create(:order, :completed, total: 15_000)
stub_request(:put, %r{https://storage.googleapis.com/invoices/.*\.pdf})
.to_return(status: 200)
perform_enqueued_jobs do
InvoiceGenerationJob.perform_later(order.id)
end
order.reload
expect(order.invoice_url).to match(/storage.googleapis.com/)
expect(order.invoice_generated_at).to be_within(1.second).of(Time.current)
# Verify PDF was generated
expect(WebMock).to have_requested(:put, %r{storage.googleapis.com})
.with { |req| req.headers['Content-Type'] == 'application/pdf' }
end
it "retries on storage failure" do
order = create(:order, :completed)
stub_request(:put, %r{storage.googleapis.com})
.to_return(status: 503).times(2)
.then.to_return(status: 200)
perform_enqueued_jobs do
InvoiceGenerationJob.perform_later(order.id)
end
expect(order.reload.invoice_url).to be_present
expect(WebMock).to have_requested(:put, %r{storage.googleapis.com}).times(3)
end
end
Testing database trigger interactions and complex queries:
# spec/models/subscription_spec.rb
RSpec.describe Subscription do
it "updates account metrics when subscription status changes" do
account = create(:account, active_subscriptions: 0, mrr_cents: 0)
subscription = create(:subscription, account: account, plan_price_cents: 9900)
expect {
subscription.activate!
}.to change { account.reload.active_subscriptions }.from(0).to(1)
.and change { account.mrr_cents }.from(0).to(9900)
expect {
subscription.cancel!
}.to change { account.reload.active_subscriptions }.from(1).to(0)
.and change { account.mrr_cents }.from(9900).to(0)
end
it "maintains referential integrity with payment methods" do
subscription = create(:subscription, :active)
payment_method = subscription.payment_method
expect {
subscription.destroy
}.to raise_error(ActiveRecord::InvalidForeignKey)
subscription.cancel!
subscription.destroy
expect(PaymentMethod.exists?(payment_method.id)).to be true
end
end
Tools & Ecosystem
RSpec dominates Ruby integration testing with extensive Rails support. RSpec Rails provides request specs, controller specs, and integration helpers. Request specs test HTTP endpoints with full request/response cycles. Controller specs focus on controller logic with lighter setup. Feature specs use Capybara for browser-like testing.
Minitest offers similar capabilities with Rails integration tests. Minitest ships with Rails and requires no additional dependencies. Integration tests inherit from ActionDispatch::IntegrationTest and provide methods for making requests, following redirects, and asserting responses.
Capybara enables browser-based integration testing. It simulates user interactions: clicking links, filling forms, submitting data. Capybara works with various drivers including Rack::Test for non-JavaScript tests and Selenium or Cuprite for JavaScript support:
# spec/features/user_registration_spec.rb
RSpec.describe "User registration", type: :feature do
it "creates account and redirects to dashboard", js: true do
visit "/signup"
fill_in "Email", with: "user@example.com"
fill_in "Password", with: "SecurePass123!"
fill_in "Password Confirmation", with: "SecurePass123!"
click_button "Create Account"
expect(page).to have_current_path(dashboard_path)
expect(page).to have_content("Welcome, user@example.com")
user = User.find_by(email: "user@example.com")
expect(user).to be_present
expect(user.confirmed?).to be false
end
end
Factory Bot (formerly FactoryGirl) creates test data with realistic associations and attributes. Factories define templates for models with sensible defaults and traits for variations. Sequences generate unique values. Callbacks handle post-creation setup:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
password { "password123" }
trait :confirmed do
confirmed_at { 2.days.ago }
end
trait :admin do
role { :admin }
end
trait :with_subscription do
after(:create) do |user|
create(:subscription, :active, user: user)
end
end
end
end
WebMock stubs HTTP requests at the network level. It prevents real HTTP requests during tests and provides matchers for verifying requests:
# spec/support/webmock.rb
require 'webmock/rspec'
RSpec.configure do |config|
config.before(:each) do
WebMock.disable_net_connect!(
allow_localhost: true,
allow: ['chromedriver.storage.googleapis.com']
)
end
end
VCR records HTTP interactions and replays them in subsequent test runs. This provides real API responses without actual API calls. VCR cassettes are YAML files containing request/response pairs:
# spec/support/vcr.rb
VCR.configure do |config|
config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
config.hook_into :webmock
config.default_cassette_options = {
record: :new_episodes,
match_requests_on: [:method, :uri, :body]
}
end
Database Cleaner handles test database cleanup. It supports multiple strategies: transaction, truncation, deletion. Transaction is fastest but incompatible with some scenarios. Truncation works universally but runs slower:
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do |example|
DatabaseCleaner.strategy = example.metadata[:js] ? :truncation : :transaction
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Rack::Test provides lightweight HTTP request testing without full browser overhead. It works for API endpoints and non-JavaScript pages:
# spec/requests/api/v1/users_spec.rb
RSpec.describe "API::V1::Users" do
it "lists users with pagination" do
create_list(:user, 25)
get "/api/v1/users", params: { page: 1, per_page: 10 }
expect(response).to have_http_status(:ok)
data = JSON.parse(response.body)
expect(data["users"].length).to eq(10)
expect(data["meta"]["total"]).to eq(25)
end
end
Shoulda Matchers provides RSpec matchers for common Rails validations and associations:
RSpec.describe Order do
it { should belong_to(:customer) }
it { should have_many(:line_items) }
it { should validate_presence_of(:total) }
it { should validate_numericality_of(:total).is_greater_than(0) }
end
Timecop or ActiveSupport::Testing::TimeHelpers control time in tests:
it "expires trial subscriptions after 14 days" do
subscription = create(:subscription, :trial, created_at: 13.days.ago)
expect(subscription.trial?).to be true
travel_to 2.days.from_now do
subscription.check_expiration
expect(subscription.reload.expired?).to be true
end
end
Common Patterns
The repository pattern abstracts data access and external service calls behind interfaces. Tests swap production repositories for in-memory or stubbed versions:
# app/repositories/user_repository.rb
class UserRepository
def find(id)
User.find(id)
end
def create(attributes)
User.create!(attributes)
end
end
# spec/repositories/fake_user_repository.rb
class FakeUserRepository
def initialize
@users = {}
@next_id = 1
end
def find(id)
@users[id] or raise ActiveRecord::RecordNotFound
end
def create(attributes)
user = OpenStruct.new(attributes.merge(id: @next_id))
@users[@next_id] = user
@next_id += 1
user
end
end
# spec/services/user_service_spec.rb
RSpec.describe UserService do
it "creates user with generated token" do
repository = FakeUserRepository.new
service = UserService.new(repository)
user = service.create_user(email: "test@example.com")
expect(user.email).to eq("test@example.com")
expect(user.auth_token).to be_present
end
end
The test builder pattern creates complex test scenarios with fluent interfaces:
# spec/support/scenario_builder.rb
class ScenarioBuilder
def initialize
@user = nil
@orders = []
end
def with_user(**attributes)
@user = create(:user, attributes)
self
end
def with_orders(count, **attributes)
count.times do
@orders << create(:order, user: @user, **attributes)
end
self
end
def with_subscription(plan:)
create(:subscription, user: @user, plan: plan)
self
end
def build
OpenStruct.new(user: @user, orders: @orders)
end
end
# Usage in tests
scenario = ScenarioBuilder.new
.with_user(email: "vip@example.com")
.with_orders(3, status: :completed)
.with_subscription(plan: :premium)
.build
expect(scenario.user.orders.count).to eq(3)
The shared examples pattern extracts common test behaviors:
# spec/support/shared_examples/api_authentication.rb
RSpec.shared_examples "requires authentication" do
it "returns 401 without credentials" do
make_request
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with invalid token" do
make_request(headers: { 'Authorization' => 'Bearer invalid' })
expect(response).to have_http_status(:unauthorized)
end
end
# spec/requests/api/orders_spec.rb
RSpec.describe "API::Orders" do
describe "GET /api/orders" do
def make_request(headers: {})
get "/api/orders", headers: headers
end
it_behaves_like "requires authentication"
end
end
The contract testing pattern verifies service boundaries match consumer expectations:
# spec/contracts/payment_gateway_contract_spec.rb
RSpec.describe "Payment gateway contract" do
it "charges customer card" do
response = PaymentGateway.charge(
amount: 5000,
currency: "usd",
token: "tok_visa"
)
expect(response).to include(
id: a_string_matching(/^ch_/),
status: "succeeded",
amount: 5000,
currency: "usd"
)
end
it "returns error for declined cards" do
response = PaymentGateway.charge(
amount: 5000,
currency: "usd",
token: "tok_chargeDeclined"
)
expect(response).to include(
status: "failed",
error: hash_including(
code: "card_declined",
message: a_string_matching(/declined/)
)
)
end
end
The test data builder pattern encapsulates complex object creation:
# spec/support/builders/order_builder.rb
class OrderBuilder
def initialize
@attributes = default_attributes
@line_items = []
end
def with_line_item(product:, quantity:, price: nil)
@line_items << {
product: product,
quantity: quantity,
price: price || product.price
}
self
end
def with_shipping(method:, cost:)
@attributes[:shipping_method] = method
@attributes[:shipping_cost] = cost
self
end
def build
order = create(:order, @attributes)
@line_items.each do |item|
create(:line_item, order: order, **item)
end
order.reload
end
private
def default_attributes
{ customer: create(:customer) }
end
end
Error Handling & Edge Cases
Database constraint violations expose integration issues absent in unit tests. Foreign key constraints, unique indexes, and check constraints all require proper handling:
# spec/models/order_spec.rb
RSpec.describe Order do
it "prevents duplicate order numbers" do
create(:order, order_number: "ORD-001")
expect {
create(:order, order_number: "ORD-001")
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it "cascades deletion to line items" do
order = create(:order)
line_items = create_list(:line_item, 3, order: order)
expect {
order.destroy
}.to change(LineItem, :count).by(-3)
end
it "prevents orphaned line items" do
line_item = build(:line_item, order: nil)
expect(line_item.valid?).to be false
expect(line_item.errors[:order]).to include("must exist")
end
end
Race conditions in concurrent scenarios require careful testing:
# spec/models/inventory_spec.rb
RSpec.describe Inventory do
it "prevents overselling with pessimistic locking" do
product = create(:product, stock_count: 1)
threads = 2.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
product.reload
product.with_lock do
if product.stock_count >= 1
product.update!(stock_count: product.stock_count - 1)
true
else
false
end
end
rescue ActiveRecord::StaleObjectError
false
end
end
end
results = threads.map(&:value)
expect(results.count(true)).to eq(1)
expect(product.reload.stock_count).to eq(0)
end
end
External service failures require retry logic and fallback behavior:
# spec/services/geocoding_service_spec.rb
RSpec.describe GeocodingService do
it "retries on timeout errors" do
call_count = 0
stub_request(:get, %r{api.geocode.com})
.to_return do
call_count += 1
if call_count < 3
{ status: 504 }
else
{ status: 200, body: { lat: 41.8781, lng: -87.6298 }.to_json }
end
end
service = GeocodingService.new
result = service.geocode("Chicago, IL")
expect(result).to include(lat: 41.8781, lng: -87.6298)
expect(call_count).to eq(3)
end
it "falls back to cached data on service failure" do
Rails.cache.write("geocode:Chicago, IL", { lat: 41.8781, lng: -87.6298 })
stub_request(:get, %r{api.geocode.com})
.to_return(status: 503)
service = GeocodingService.new
result = service.geocode("Chicago, IL")
expect(result).to include(lat: 41.8781, lng: -87.6298)
end
end
Transaction isolation issues emerge when testing multi-database operations:
# spec/services/data_sync_service_spec.rb
RSpec.describe DataSyncService do
it "maintains consistency across databases" do
primary_record = create(:user, email: "user@example.com")
service = DataSyncService.new
service.sync_to_analytics(primary_record)
analytics_record = AnalyticsDB::User.find_by(primary_id: primary_record.id)
expect(analytics_record.email).to eq("user@example.com")
expect(analytics_record.synced_at).to be_within(1.second).of(Time.current)
end
it "rolls back both databases on failure" do
primary_record = create(:user)
allow(AnalyticsDB::User).to receive(:create!).and_raise(StandardError)
service = DataSyncService.new
expect {
service.sync_to_analytics(primary_record)
}.to raise_error(StandardError)
expect(AnalyticsDB::User.find_by(primary_id: primary_record.id)).to be_nil
end
end
Timing-dependent operations need deterministic test approaches:
# spec/jobs/scheduled_report_job_spec.rb
RSpec.describe ScheduledReportJob do
it "generates report for correct time period" do
travel_to Time.zone.parse("2024-01-15 00:00:00") do
create(:transaction, created_at: "2024-01-01", amount: 100)
create(:transaction, created_at: "2024-01-14", amount: 200)
create(:transaction, created_at: "2024-01-15", amount: 300)
ScheduledReportJob.perform_now("monthly")
report = Report.last
expect(report.period_start).to eq(Date.parse("2024-01-01"))
expect(report.period_end).to eq(Date.parse("2024-01-14"))
expect(report.total_amount).to eq(300)
end
end
end
Reference
Integration Test Types
| Type | Scope | Dependencies | Example |
|---|---|---|---|
| Request specs | HTTP endpoint to response | Database, jobs, external APIs | Testing POST /orders creates order and sends email |
| Service integration | Service layer with dependencies | Database, external services | Testing payment processor calls API and updates records |
| Model integration | Model with database | Database only | Testing model callbacks trigger related updates |
| Job integration | Background job execution | Database, external services | Testing job processes records and calls webhooks |
| Feature specs | User workflow | Full stack with browser | Testing complete checkout flow with JavaScript |
Test Isolation Strategies
| Strategy | Speed | Compatibility | Use Case |
|---|---|---|---|
| Transactional fixtures | Fastest | Most scenarios | Default for tests without JavaScript or threading |
| Database truncation | Slower | JavaScript, threads | Feature specs with Selenium, concurrent operations |
| Database deletion | Slowest | All scenarios | Legacy databases without truncation support |
| Mixed approach | Balanced | All scenarios | Transaction by default, truncation for specific tests |
Factory Bot Associations
| Pattern | Syntax | Use Case |
|---|---|---|
| Direct association | association :user | Simple belongs_to relationship |
| Custom factory | association :author, factory: :user | Using different factory for association |
| Explicit attributes | association :user, email: 'test@example.com' | Override associated attributes |
| Polymorphic | association :commentable, factory: :post | Polymorphic associations |
| After create callback | after(:create) block | Complex setup requiring persisted records |
WebMock Request Matching
| Matcher | Purpose | Example |
|---|---|---|
| method | HTTP method | with(method: :post) |
| uri | Request URL | to_return(url: 'https://api.example.com') |
| body | Request body | with(body: hash_including(amount: 5000)) |
| headers | Request headers | with(headers: {'Authorization' => 'Bearer token'}) |
| query | URL parameters | with(query: {page: 1, per_page: 10}) |
RSpec Request Helpers
| Helper | Purpose | Example |
|---|---|---|
| get, post, put, delete | HTTP requests | post '/orders', params: {order: attributes} |
| response | Last response | expect(response).to have_http_status(:created) |
| parsed_body | JSON response | expect(response.parsed_body['id']).to eq(order.id) |
| follow_redirect! | Follow redirect | follow_redirect! after POST |
| headers | Set headers | get '/api/users', headers: {'Authorization' => token} |
Database Cleaner Configuration
| Option | Values | Effect |
|---|---|---|
| strategy | :transaction, :truncation, :deletion | Cleanup method used |
| clean_with | Same as strategy | One-time cleanup before suite |
| orm | :active_record, :mongoid | ORM adapter to use |
| pre_count | true, false | Check counts before cleaning |
| cache_tables | true, false | Cache table list for performance |
VCR Configuration Options
| Option | Purpose | Example |
|---|---|---|
| cassette_library_dir | Cassette storage location | 'spec/fixtures/vcr_cassettes' |
| hook_into | HTTP mocking library | :webmock |
| record | Recording mode | :new_episodes, :once, :all |
| match_requests_on | Request matching criteria | [:method, :uri, :body] |
| filter_sensitive_data | Hide sensitive values | filter_sensitive_data('') { ENV['API_KEY'] } |
Capybara Matchers
| Matcher | Purpose | Example |
|---|---|---|
| have_content | Text presence | expect(page).to have_content('Welcome') |
| have_selector | CSS selector match | expect(page).to have_selector('.error') |
| have_current_path | URL verification | expect(page).to have_current_path('/dashboard') |
| have_field | Form field presence | expect(page).to have_field('Email') |
| have_button | Button presence | expect(page).to have_button('Submit') |
Test Data Cleanup Commands
| Command | Effect | Speed | Use Case |
|---|---|---|---|
| DatabaseCleaner.clean | Runs configured strategy | Depends on strategy | After each test |
| DatabaseCleaner.clean_with(:truncation) | Truncates all tables | Slow | Before suite |
| DatabaseCleaner.start | Begins transaction | Instant | Before test with transaction strategy |
| ActiveRecord::Base.connection.truncate_tables | Direct truncation | Fast | Custom cleanup |
| Model.delete_all | Delete without callbacks | Fast | Simple model cleanup |