API & Controller Integration Testing Guide

Created: March 2026
Framework: Minitest — ActionDispatch::IntegrationTest


Overview

This project uses Rails' built-in ActionDispatch::IntegrationTest for all controller and API integration tests. There is no dedicated API-spec gem — the full HTTP dispatch stack (routing → before_actions → controller → response) is exercised directly in Minitest.

The canonical reference implementation is:

  • test/controllers/www/quote_builder_controller_test.rb — integration + unit patterns, stubbing, JSON assertions
  • test/controllers/crm/edi_communication_logs_controller_test.rb — CRM host setup, Devise auth, Ransack params

Gem Stack

Gem Role in API tests
minitest (Rails built-in) Base test framework
factory_bot_rails ~> 6.4 Seed test data
faker ~> 3.2 Generate realistic fake payloads
devise test helpers Sign-in helpers for authenticated endpoints
webmock ~> 3.19 Stub all outbound HTTP — no live network in tests
vcr ~> 6.2 Record/replay HTTP cassettes for external service tests
minitest-mock Inline Ruby object stubbing (Object#stub)
shoulda-matchers ~> 6.5 One-liner model/response assertions
timecop ~> 0.9 Freeze/travel time for date-sensitive endpoints
database_cleaner-active_record ~> 2.1 Clean DB between tests

Base Class Selection

Endpoint type Base class
API v1 JSON (api.* subdomain) ActionDispatch::IntegrationTest
CRM controllers ActionDispatch::IntegrationTest
WWW controllers ActionDispatch::IntegrationTest
Controller unit (private methods only) ActiveSupport::TestCase
# Integration test — exercises full HTTP stack
class Api::V1::BomControllerTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers
end

# Unit test — controller private method only (no HTTP dispatch)
class Www::QuoteBuilderControllerTest < ActiveSupport::TestCase
end

Setup Patterns

CRM endpoint

setup do
  employee = create(:employee, :admin)
  @account = employee.

  host! CRM_HOSTNAME_WITHOUT_PORT
  https!(true)
  default_url_options[:locale] = 'en-US'

   @account
  # Flush Warden.on_next_request callback before any parallel teardown can clear it
  get some_crm_path(locale: 'en-US')
end

WWW endpoint (customer-facing)

setup do
  @customer = create(:customer, :with_account)

  host! WEB_HOSTNAME_WITHOUT_PORT
  https!(true)
  default_url_options[:locale] = 'en-US'

   @customer.
  get root_path(locale: 'en-US')  # flush Warden callback
end

API v1 endpoint (token/key auth or public)

setup do
  host! API_HOSTNAME_WITHOUT_PORT  # api.* subdomain — required for routing constraint
  https!(true)
  default_url_options[:locale] = 'en-US'
end

Making Requests

# JSON GET
get bom_url(locale: 'en-US'), params: { product_line: 'tz' }, as: :json

# JSON POST with body
post accounts_url, params: { account: { email: 'test@example.com' } }, as: :json

# With Authorization header (token-authenticated endpoints)
get protected_url, as: :json, headers: { 'Authorization' => "Bearer #{token}" }

# HTML request (default)
get crm_orders_path(locale: 'en-US')

Asserting Responses

Status codes

assert_response :success             # 200
assert_response :created             # 201
assert_response :no_content          # 204
assert_response :unprocessable_entity  # 422
assert_response :not_found           # 404
assert_response :unauthorized        # 401
assert_response :bad_request         # 400

JSON body

json = JSON.parse(response.body)

# Key presence
assert json.key?('output'), "Expected 'output' key; got: #{json.keys.inspect}"

# Value equality
assert_equal 'expected value', json['field']
assert_equal 1, json['output'].length

# Nested access
assert_equal 3, json.dig('data', 'zones').length

# Error responses from Api::V1::BaseController
assert_equal 'error message', json['error_message']
assert json['error'].present?

Side effects

Always assert persisted changes — don't only check the HTTP response:

assert_equal 'transmitted', invoice.reload.transmission_state
assert_not_includes Invoice.missing_edi_810, invoice.reload
assert_enqueued_with(job: MyWorker, args: [record.id])

HTML responses

assert_response :success
assert_select 'a', text: /Clear/
assert_redirected_to some_path(locale: 'en-US')

Stubbing External Services

WebMock (HTTP stubs)

All outbound HTTP must be stubbed. Tests that hit a live network will raise WebMock::NetConnectNotAllowedError.

stub_request(:post, /api\.stripe\.com/)
  .to_return(
    status: 200,
    body: { id: 'ch_123', status: 'succeeded' }.to_json,
    headers: { 'Content-Type' => 'application/json' }
  )

Object#stub (service/class stubs)

Use Minitest's built-in Object#stub for service boundaries:

FakeResult = Service::Result.new(output: bom, error_status: nil, error_message: nil,
                                 last_modified: Time.current, etag: 'abc')

MyService.stub(:call, FakeResult) do
  get some_url, as: :json
end

Mutex for parallel safety

Object#stub on class-level methods is not thread-safe in parallel test runs. Protect with a Mutex:

# Define at file scope (not inside the test class)
MY_STUB_MUTEX = Mutex.new

test 'some test' do
  MY_STUB_MUTEX.synchronize do
    MyService.stub(:new, fake_service) do
      get some_url, as: :json
    end
  end
end

Coverage Checklist Per Endpoint

For each controller action, write tests covering:

Scenario What to assert
Happy path — valid params Status 200/201, expected JSON shape
Invalid / missing required params Status 422, error or error_message key
Record not found Status 404, error key
Unauthenticated request (if auth required) Status 401
Boundary / edge values No crash, correct response
Side effects record.reload.state, enqueued jobs, DB changes

File and Class Naming

File path Class name
test/controllers/api/v1/bom_controller_test.rb Api::V1::BomControllerTest
test/controllers/crm/orders_controller_test.rb Crm::OrdersControllerTest
test/controllers/www/quote_builder_controller_test.rb Www::QuoteBuilderControllerTest

File header comment (required)

# frozen_string_literal: true

require 'test_helper'

# Brief description of what is covered and why.
#
# AppSignal regression references belong here, e.g.:
#   - AppSignal #1234  NoMethodError: undefined method '[]=' for Service::Result
#
# Run: mise exec -- bin/rails test test/controllers/api/v1/bom_controller_test.rb
class Api::V1::BomControllerTest < ActionDispatch::IntegrationTest

Running Tests

# Single file
mise exec -- bin/rails test test/controllers/api/v1/bom_controller_test.rb

# Single test by name
mise exec -- bin/rails test test/controllers/api/v1/bom_controller_test.rb -n "test_GET_bom_returns_200_with_output"

# All controller tests
mise exec -- bin/rails test test/controllers/

All commands must be prefixed with mise exec -- — the project pins Ruby/Node versions via mise and bare commands pick up the wrong runtime.


Anti-Patterns

  • Making live HTTP calls in tests — always stub with WebMock
  • Using RSpec syntax or expect(...).to assertions
  • Asserting implementation details (private method internals) instead of HTTP outcomes
  • Skipping side-effect assertions (only checking assert_response :success)
  • Missing the Warden flush pattern, causing flaky auth failures in parallel runs
  • Stubbing class-level methods without a Mutex in parallel test suites

Related Documentation