Testing Rails APIs with RSpec — My Practical Approach
by Eric Hanson, Backend Developer at Clean Systems Consulting
What layer to test and why
Rails gives you three places to test an API endpoint: controller specs, request specs, and integration tests. Controller specs (the old standard) test the controller in isolation — they mock the request and don't exercise routing, middleware, or serializers. Request specs make a real HTTP request through the full stack. Integration tests are request specs with database state assertions added.
Controller specs are mostly obsolete for API testing. The value of an API test is verifying that the HTTP contract — the route, the authentication, the serialized response shape, the status code — works correctly end-to-end. A controller spec that mocks the request and stubs the serializer doesn't verify any of that.
Request specs, backed by a database, are the right tool. They run fast enough for a reasonable suite and test what actually matters: what the client sends, what the server returns.
The base setup
# spec/support/api_helpers.rb
module ApiHelpers
def json_response
JSON.parse(response.body, symbolize_names: true)
end
def auth_headers(user)
token = Auth::TokenService.issue(user_id: user.id)
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
end
def post_json(path, params: {}, headers: {})
post path, params: params.to_json, headers: headers.merge("Content-Type" => "application/json")
end
def patch_json(path, params: {}, headers: {})
patch path, params: params.to_json, headers: headers.merge("Content-Type" => "application/json")
end
end
RSpec.configure do |config|
config.include ApiHelpers, type: :request
end
json_response parses the body once and returns a symbolized hash — no repeated JSON.parse calls in every test. auth_headers issues a real token using the same token service the application uses. post_json and patch_json handle the content-type header consistently so individual tests don't repeat it.
A complete request spec
RSpec.describe "POST /api/v1/orders", type: :request do
let(:user) { create(:user) }
let(:cart) { create(:cart, :with_items, user: user) }
describe "creating an order" do
context "with valid params and authentication" do
before do
allow(PaymentGateway).to receive(:charge).and_return(
instance_double(PaymentGateway::Charge, ok?: true, id: "ch_test_123")
)
end
it "returns 201 with the order data" do
post_json(
"/api/v1/orders",
params: { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
headers: auth_headers(user)
)
expect(response).to have_http_status(:created)
expect(json_response[:data]).to include(
status: "pending",
currency: "USD"
)
expect(json_response[:data][:id]).to be_present
end
it "creates the order in the database" do
expect {
post_json(
"/api/v1/orders",
params: { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
headers: auth_headers(user)
)
}.to change(Order, :count).by(1)
end
end
context "without authentication" do
it "returns 401" do
post_json "/api/v1/orders", params: { order: { cart_id: cart.id } }
expect(response).to have_http_status(:unauthorized)
end
it "returns a machine-readable error code" do
post_json "/api/v1/orders", params: { order: { cart_id: cart.id } }
expect(json_response[:errors].first[:code]).to eq("token_invalid")
end
end
context "when payment is declined" do
before do
allow(PaymentGateway).to receive(:charge).and_return(
instance_double(PaymentGateway::Charge, ok?: false, decline_reason: "insufficient_funds")
)
end
it "returns 422" do
post_json(
"/api/v1/orders",
params: { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
headers: auth_headers(user)
)
expect(response).to have_http_status(:unprocessable_entity)
end
it "does not create an order" do
expect {
post_json(
"/api/v1/orders",
params: { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
headers: auth_headers(user)
)
}.not_to change(Order, :count)
end
end
context "when cart belongs to another user" do
let(:other_cart) { create(:cart, :with_items) }
it "returns 403" do
post_json(
"/api/v1/orders",
params: { order: { cart_id: other_cart.id, payment_method: "pm_test_visa" } },
headers: auth_headers(user)
)
expect(response).to have_http_status(:forbidden)
end
end
end
end
Several things to observe in this structure:
The payment gateway is stubbed because it's an external service. Hitting real external services in request specs makes tests slow, non-deterministic, and dependent on network availability. The stub uses instance_double — verified against the real class interface.
Each it block tests one thing. The status code test and the database state test are separate. When either fails, the failure message is precise.
Authorization failure gets its own context block with both the status code and the error code tested. Clients integrate against error codes, not just status codes.
What to assert in a request spec
The assertions worth making in every request spec:
HTTP status code — always. have_http_status(:created) over have_http_status(201) for readability. Both are equivalent; the symbol form documents intent.
Response shape for success — assert the presence and type of fields the client depends on, not the exact value of every field. expect(json_response[:data][:id]).to be_present is more useful than expect(json_response[:data][:id]).to eq(order.id) when the order hasn't been created yet.
Error codes for failure — expect(json_response[:errors].first[:code]).to eq("token_invalid"). Clients branch on these codes. Test them explicitly.
Database state changes — use change(Model, :count) for creation and deletion. Don't query the database to find the created record and assert its attributes in a request spec — that's testing the service object's behavior, which belongs in service object specs.
What not to assert:
Exact timestamps — created_at, updated_at values are correct by definition if the record was created. Asserting their exact value makes tests fragile to timing.
Every field in the response — testing that a response includes 30 fields is brittle maintenance overhead. Test the fields the client actually uses; let schema validation cover completeness.
Internal implementation details — which service object was called, which method was invoked on which model. Request specs test the HTTP contract. Unit specs test the implementation.
Shared examples for common API behaviors
Authentication failure, authorization failure, and not-found responses follow the same pattern across every endpoint. Shared examples eliminate the repetition:
# spec/support/shared_examples/api_authentication.rb
RSpec.shared_examples "requires authentication" do
it "returns 401 without a token" do
request_without_auth
expect(response).to have_http_status(:unauthorized)
end
it "returns a token_invalid error code" do
request_without_auth
expect(json_response[:errors].first[:code]).to eq("token_invalid")
end
end
RSpec.shared_examples "returns not found" do
it "returns 404" do
expect(response).to have_http_status(:not_found)
end
it "returns a not_found error code" do
expect(json_response[:errors].first[:code]).to eq("not_found")
end
end
Usage:
RSpec.describe "GET /api/v1/orders/:id", type: :request do
context "without authentication" do
let(:request_without_auth) { get "/api/v1/orders/1" }
it_behaves_like "requires authentication"
end
context "when order does not exist" do
before { get "/api/v1/orders/99999", headers: auth_headers(user) }
it_behaves_like "returns not found"
end
end
The shared example defines the behavior; the context defines the setup. Every endpoint that requires authentication gets the same two assertions without rewriting them.
Pagination testing
Cursor-based pagination requires testing the cursor mechanics, not just the data:
RSpec.describe "GET /api/v1/orders", type: :request do
let(:user) { create(:user) }
before { create_list(:order, 5, user: user) }
it "returns the first page with a next cursor" do
get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(json_response[:data].length).to eq(3)
expect(json_response[:meta][:has_more]).to be true
expect(json_response[:meta][:next_cursor]).to be_present
end
it "returns the second page using the cursor" do
get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)
cursor = json_response[:meta][:next_cursor]
get "/api/v1/orders", params: { per_page: 3, cursor: cursor }, headers: auth_headers(user)
expect(json_response[:data].length).to eq(2)
expect(json_response[:meta][:has_more]).to be false
end
it "does not return duplicate records across pages" do
get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)
first_page_ids = json_response[:data].map { |o| o[:id] }
cursor = json_response[:meta][:next_cursor]
get "/api/v1/orders", params: { per_page: 3, cursor: cursor }, headers: auth_headers(user)
second_page_ids = json_response[:data].map { |o| o[:id] }
expect(first_page_ids & second_page_ids).to be_empty
end
end
The no-duplicate-records test is the one most teams skip and the one that catches cursor implementation bugs. Two sequential requests should never return the same record. Test it explicitly.
The test suite organization that survives growth
As the API grows, request specs organized by endpoint become hard to navigate. Organize by resource with consistent file naming:
spec/
requests/
api/
v1/
orders/
create_spec.rb
index_spec.rb
show_spec.rb
update_spec.rb
users/
show_spec.rb
update_spec.rb
One file per action, named for the HTTP verb. create_spec.rb tests POST. update_spec.rb tests PATCH. A developer looking for the test for order creation knows exactly where to find it.
Tags make it possible to run subsets:
RSpec.describe "POST /api/v1/orders", type: :request, tags: [:orders, :create] do
rspec --tag orders # run all order specs
rspec --tag create # run all create action specs
rspec spec/requests/api # run all request specs
Combined with CI parallelization — splitting the request spec suite across workers by file — a request spec suite with 500 specs runs in under two minutes on most CI configurations. The file-per-action organization makes even splitting natural: each file is an independent unit with no cross-file dependencies.