Skip to content

API & Controller Integration Testing Guide

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


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

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

Endpoint typeBase class
API v1 JSON (api.* subdomain)ActionDispatch::IntegrationTest
CRM controllersActionDispatch::IntegrationTest
WWW controllersActionDispatch::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 do
employee = create(:employee, :admin)
@account = employee.account
host! CRM_HOSTNAME_WITHOUT_PORT
https!(true)
default_url_options[:locale] = 'en-US'
sign_in @account
# Flush Warden.on_next_request callback before any parallel teardown can clear it
get some_crm_path(locale: 'en-US')
end
setup do
@customer = create(:customer, :with_account)
host! WEB_HOSTNAME_WITHOUT_PORT
https!(true)
default_url_options[:locale] = 'en-US'
sign_in @customer.account
get root_path(locale: 'en-US') # flush Warden callback
end

API v1 endpoint (token/key auth or public)

Section titled “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

# 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')

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 = 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?

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])
assert_response :success
assert_select 'a', text: /Clear/
assert_redirected_to some_path(locale: 'en-US')

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' }
)

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

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

For each controller action, write tests covering:

ScenarioWhat to assert
Happy path — valid paramsStatus 200/201, expected JSON shape
Invalid / missing required paramsStatus 422, error or error_message key
Record not foundStatus 404, error key
Unauthenticated request (if auth required)Status 401
Boundary / edge valuesNo crash, correct response
Side effectsrecord.reload.state, enqueued jobs, DB changes

File pathClass name
test/controllers/api/v1/bom_controller_test.rbApi::V1::BomControllerTest
test/controllers/crm/orders_controller_test.rbCrm::OrdersControllerTest
test/controllers/www/quote_builder_controller_test.rbWww::QuoteBuilderControllerTest
# 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

Terminal window
# 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.


  • 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