CrackedRuby CrackedRuby

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