API & Controller Integration Testing Guide
Created: March 2026
Framework: Minitest — ActionDispatch::IntegrationTest
Overview
Section titled “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
Section titled “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
Section titled “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 stackclass Api::V1::BomControllerTest < ActionDispatch::IntegrationTest include Devise::Test::IntegrationHelpersend
# Unit test — controller private method only (no HTTP dispatch)class Www::QuoteBuilderControllerTest < ActiveSupport::TestCaseendSetup Patterns
Section titled “Setup Patterns”CRM endpoint
Section titled “CRM endpoint”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')endWWW endpoint (customer-facing)
Section titled “WWW endpoint (customer-facing)”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 callbackendAPI 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'endMaking Requests
Section titled “Making Requests”# JSON GETget bom_url(locale: 'en-US'), params: { product_line: 'tz' }, as: :json
# JSON POST with bodypost 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
Section titled “Asserting Responses”Status codes
Section titled “Status codes”assert_response :success # 200assert_response :created # 201assert_response :no_content # 204assert_response :unprocessable_entity # 422assert_response :not_found # 404assert_response :unauthorized # 401assert_response :bad_request # 400JSON body
Section titled “JSON body”json = JSON.parse(response.body)
# Key presenceassert json.key?('output'), "Expected 'output' key; got: #{json.keys.inspect}"
# Value equalityassert_equal 'expected value', json['field']assert_equal 1, json['output'].length
# Nested accessassert_equal 3, json.dig('data', 'zones').length
# Error responses from Api::V1::BaseControllerassert_equal 'error message', json['error_message']assert json['error'].present?Side effects
Section titled “Side effects”Always assert persisted changes — don’t only check the HTTP response:
assert_equal 'transmitted', invoice.reload.transmission_stateassert_not_includes Invoice.missing_edi_810, invoice.reloadassert_enqueued_with(job: MyWorker, args: [record.id])HTML responses
Section titled “HTML responses”assert_response :successassert_select 'a', text: /Clear/assert_redirected_to some_path(locale: 'en-US')Stubbing External Services
Section titled “Stubbing External Services”WebMock (HTTP stubs)
Section titled “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)
Section titled “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: :jsonendMutex for parallel safety
Section titled “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 endendCoverage Checklist Per Endpoint
Section titled “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
Section titled “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)
Section titled “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.rbclass Api::V1::BomControllerTest < ActionDispatch::IntegrationTestRunning Tests
Section titled “Running Tests”# Single filemise exec -- bin/rails test test/controllers/api/v1/bom_controller_test.rb
# Single test by namemise exec -- bin/rails test test/controllers/api/v1/bom_controller_test.rb -n "test_GET_bom_returns_200_with_output"
# All controller testsmise 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
Section titled “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
Related Documentation
Section titled “Related Documentation”- Skills Index
- System Tests (Turbo/Stimulus)
- Database Migrations Guide