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 assertionstest/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.account
host! CRM_HOSTNAME_WITHOUT_PORT
https!(true)
[: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
WWW endpoint (customer-facing)
setup do
@customer = create(:customer, :with_account)
host! WEB_HOSTNAME_WITHOUT_PORT
https!(true)
[: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)
setup do
host! API_HOSTNAME_WITHOUT_PORT # api.* subdomain — required for routing constraint
https!(true)
[: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(...).toassertions - 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