Rails API Testing Patterns: Request Specs, JSON Assertions, and Authentication
Rails request specs are the right tool for testing APIs—they exercise the full stack from routing through serialization without a real browser. This post covers JSON assertion patterns, authentication header strategies, API versioning in tests, and shared helpers that keep API specs readable and maintainable.
Rails provides three layers for testing controllers: controller specs, request specs, and system specs. For APIs, request specs are the correct choice. They test the full request/response cycle—routing, middleware, authentication, serialization—without the overhead of a browser. They're faster than system tests and more realistic than controller specs, which bypass Rack middleware.
This post focuses on the patterns that make API request specs useful at scale: structured JSON assertions, reusable authentication helpers, versioned API testing, and pagination/error response contracts.
Setup: Request Spec Foundation
# spec/rails_helper.rb
RSpec.configure do |config|
config.include Rails.application.routes.url_helpers
end
# spec/support/api_helpers.rb
module ApiHelpers
def json_body
@json_body ||= JSON.parse(response.body, symbolize_names: true)
end
def json_data
json_body[:data]
end
def json_errors
json_body[:errors]
end
def json_meta
json_body[:meta]
end
def post_json(path, params: {}, headers: {})
post path,
params: params.to_json,
headers: headers.merge("Content-Type" => "application/json", "Accept" => "application/json")
end
def patch_json(path, params: {}, headers: {})
patch path,
params: params.to_json,
headers: headers.merge("Content-Type" => "application/json", "Accept" => "application/json")
end
end
RSpec.configure do |config|
config.include ApiHelpers, type: :request
endAuthentication Helpers
Most API tests need to authenticate. Centralizing auth header generation prevents duplication and makes it trivial to swap JWT libraries.
JWT Authentication
# spec/support/auth_helpers.rb
module AuthHelpers
def auth_headers_for(user)
token = generate_token(user)
{
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json",
"Accept" => "application/json"
}
end
def generate_token(user)
payload = {
user_id: user.id,
exp: 1.hour.from_now.to_i,
iat: Time.current.to_i
}
JWT.encode(payload, Rails.application.credentials.secret_key_base, "HS256")
end
# Expired token for testing auth failures
def expired_auth_headers_for(user)
payload = {
user_id: user.id,
exp: 1.hour.ago.to_i,
iat: 2.hours.ago.to_i
}
token = JWT.encode(payload, Rails.application.credentials.secret_key_base, "HS256")
{ "Authorization" => "Bearer #{token}" }
end
def invalid_auth_headers
{ "Authorization" => "Bearer invalid.token.value" }
end
end
RSpec.configure do |config|
config.include AuthHelpers, type: :request
endUsage:
RSpec.describe "GET /api/v1/users/me", type: :request do
let(:user) { create(:user) }
it "returns the authenticated user's profile" do
get "/api/v1/users/me", headers: auth_headers_for(user)
expect(response).to have_http_status(:ok)
expect(json_data[:id]).to eq(user.id.to_s)
expect(json_data[:attributes][:email]).to eq(user.email)
end
it "returns 401 with an expired token" do
get "/api/v1/users/me", headers: expired_auth_headers_for(user)
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with no token" do
get "/api/v1/users/me"
expect(response).to have_http_status(:unauthorized)
end
endDevise Token Auth / Session-Based Auth
module AuthHelpers
def sign_in_as(user)
post "/api/v1/sessions",
params: { email: user.email, password: user.password }.to_json,
headers: { "Content-Type" => "application/json" }
# Extract token from response headers (Devise Token Auth pattern)
{
"access-token" => response.headers["access-token"],
"client" => response.headers["client"],
"uid" => response.headers["uid"],
"token-type" => "Bearer"
}
end
endJSON Response Assertions
Consistent JSON structures deserve consistent assertion patterns. Build matchers for your specific API format.
JSON:API Format
# spec/support/matchers/json_api_matchers.rb
RSpec::Matchers.define :be_a_jsonapi_resource do |type|
match do |data|
data[:type] == type.to_s &&
data[:id].present? &&
data[:attributes].is_a?(Hash)
end
failure_message do |data|
"expected JSON:API resource of type '#{type}', got: #{data.inspect}"
end
end
RSpec::Matchers.define :include_jsonapi_relationship do |name|
match do |data|
data.dig(:relationships, name).present?
end
endRSpec.describe "GET /api/v1/articles/:id", type: :request do
let(:user) { create(:user) }
let(:article) { create(:article, :published, author: user) }
before { get "/api/v1/articles/#{article.id}", headers: auth_headers_for(user) }
it "returns 200 OK" do
expect(response).to have_http_status(:ok)
end
it "returns a JSON:API article resource" do
expect(json_data).to be_a_jsonapi_resource(:article)
end
it "includes the expected attributes" do
attrs = json_data[:attributes]
expect(attrs[:title]).to eq(article.title)
expect(attrs[:slug]).to eq(article.slug)
expect(attrs[:status]).to eq("published")
expect(attrs[:published_at]).to be_present
end
it "includes author relationship" do
expect(json_data).to include_jsonapi_relationship(:author)
end
endCustom Error Response Assertions
# spec/support/matchers/error_response_matchers.rb
RSpec::Matchers.define :be_a_validation_error_for do |field|
match do |response|
errors = JSON.parse(response.body, symbolize_names: true)[:errors]
errors&.any? { |e| e[:source]&.dig(:pointer)&.include?(field.to_s) }
end
failure_message do |response|
"expected a validation error for '#{field}' in: #{response.body}"
end
end
# Usage
expect(response).to be_a_validation_error_for(:email)Testing CRUD Endpoints: Full Pattern
A complete example of testing a resource with all standard endpoints:
# spec/requests/api/v1/articles_spec.rb
RSpec.describe "Articles API", type: :request do
let(:user) { create(:user) }
let(:headers) { auth_headers_for(user) }
describe "GET /api/v1/articles" do
let!(:published) { create_list(:article, 3, :published, author: user) }
let!(:draft) { create(:article, :draft, author: user) }
it "returns only published articles" do
get "/api/v1/articles", headers: headers
expect(response).to have_http_status(:ok)
expect(json_data.length).to eq(3)
expect(json_data.pluck(:id)).not_to include(draft.id.to_s)
end
it "returns paginated results" do
get "/api/v1/articles?page=1&per_page=2", headers: headers
expect(json_data.length).to eq(2)
expect(json_meta[:total_count]).to eq(3)
expect(json_meta[:total_pages]).to eq(2)
end
end
describe "POST /api/v1/articles" do
let(:valid_params) do
{
article: {
title: "New Article",
body: "Content here",
status: "draft"
}
}
end
context "with valid parameters" do
it "creates an article" do
expect {
post_json "/api/v1/articles", params: valid_params, headers: headers
}.to change(Article, :count).by(1)
expect(response).to have_http_status(:created)
expect(json_data[:attributes][:title]).to eq("New Article")
expect(response.headers["Location"]).to include("/api/v1/articles/")
end
end
context "with invalid parameters" do
it "returns 422 with errors" do
post_json "/api/v1/articles",
params: { article: { title: "", body: "" } },
headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json_errors).to be_present
expect(response).to be_a_validation_error_for(:title)
end
end
context "without authentication" do
it "returns 401" do
post_json "/api/v1/articles", params: valid_params
expect(response).to have_http_status(:unauthorized)
end
end
end
describe "PUT /api/v1/articles/:id" do
let(:article) { create(:article, :draft, author: user) }
it "updates the article" do
patch_json "/api/v1/articles/#{article.id}",
params: { article: { title: "Updated Title" } },
headers: headers
expect(response).to have_http_status(:ok)
expect(json_data[:attributes][:title]).to eq("Updated Title")
expect(article.reload.title).to eq("Updated Title")
end
it "returns 403 when updating another user's article" do
other_article = create(:article, :draft)
patch_json "/api/v1/articles/#{other_article.id}",
params: { article: { title: "Hijacked" } },
headers: headers
expect(response).to have_http_status(:forbidden)
end
it "returns 404 for nonexistent article" do
patch_json "/api/v1/articles/999999",
params: { article: { title: "Ghost" } },
headers: headers
expect(response).to have_http_status(:not_found)
end
end
describe "DELETE /api/v1/articles/:id" do
let!(:article) { create(:article, :draft, author: user) }
it "soft deletes the article" do
expect {
delete "/api/v1/articles/#{article.id}", headers: headers
}.not_to change(Article.unscoped, :count)
expect(response).to have_http_status(:no_content)
expect(article.reload.deleted_at).to be_present
end
end
endAPI Versioning in Tests
When your API has versions, share behavior through shared examples and avoid duplicating tests:
# spec/support/shared_examples/api_versioning.rb
RSpec.shared_examples "a versioned articles endpoint" do |version|
let(:base_path) { "/api/v#{version}/articles" }
let(:user) { create(:user) }
let(:headers) { auth_headers_for(user) }
it "responds to GET #{"/api/v#{version}/articles"}" do
get base_path, headers: headers
expect(response).not_to have_http_status(:not_found)
end
it "requires authentication" do
get base_path
expect(response).to have_http_status(:unauthorized)
end
end
# Test both versions share core behavior
RSpec.describe "Articles API v1", type: :request do
it_behaves_like "a versioned articles endpoint", 1
end
RSpec.describe "Articles API v2", type: :request do
it_behaves_like "a versioned articles endpoint", 2
# v2-specific tests
describe "includes computed fields" do
let(:user) { create(:user) }
let!(:article) { create(:article, :published) }
it "includes reading_time in v2" do
get "/api/v2/articles/#{article.id}", headers: auth_headers_for(user)
expect(json_data[:attributes][:reading_time_minutes]).to be_a(Integer)
end
end
endTesting Webhooks and Callbacks
Incoming webhooks require signature verification in most APIs. Test both valid and tampered requests:
RSpec.describe "POST /webhooks/stripe", type: :request do
let(:payload) do
{
type: "payment_intent.succeeded",
data: { object: { id: "pi_test", amount: 5000 } }
}.to_json
end
def stripe_signature(payload)
timestamp = Time.current.to_i
signed_payload = "#{timestamp}.#{payload}"
signature = OpenSSL::HMAC.hexdigest(
"SHA256",
Rails.application.credentials.stripe_webhook_secret,
signed_payload
)
"t=#{timestamp},v1=#{signature}"
end
it "processes a valid webhook" do
expect {
post "/webhooks/stripe",
params: payload,
headers: {
"Content-Type" => "application/json",
"Stripe-Signature" => stripe_signature(payload)
}
}.to have_enqueued_job(ProcessStripeWebhookJob)
expect(response).to have_http_status(:ok)
end
it "returns 400 for invalid signature" do
post "/webhooks/stripe",
params: payload,
headers: {
"Content-Type" => "application/json",
"Stripe-Signature" => "t=123,v1=invalidsig"
}
expect(response).to have_http_status(:bad_request)
end
endTesting Rate Limiting
RSpec.describe "API rate limiting", type: :request do
let(:user) { create(:user) }
it "returns 429 after exceeding rate limit" do
# Make requests up to the limit
61.times do
get "/api/v1/articles", headers: auth_headers_for(user)
end
expect(response).to have_http_status(:too_many_requests)
expect(response.headers["Retry-After"]).to be_present
expect(json_body[:error]).to include("rate limit")
end
endContent Type and Accept Header Enforcement
RSpec.describe "Content type enforcement", type: :request do
let(:user) { create(:user) }
it "rejects requests without JSON content type" do
post "/api/v1/articles",
params: { article: { title: "Test" } },
headers: auth_headers_for(user).merge("Content-Type" => "application/x-www-form-urlencoded")
expect(response).to have_http_status(:unsupported_media_type)
end
it "negotiates content type via Accept header" do
get "/api/v1/articles",
headers: auth_headers_for(user).merge("Accept" => "application/vnd.api+json")
expect(response.content_type).to include("application/vnd.api+json")
end
endShared Example Groups for Authorization
Authorization checks repeat across every protected endpoint. Share them:
RSpec.shared_examples "requires authentication" do
it "returns 401 without auth header" do
perform_request_without_auth
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with invalid token" do
perform_request_without_auth
expect(json_body[:error]).to include("unauthorized")
end
end
RSpec.shared_examples "requires admin role" do
let(:regular_user) { create(:user) }
it "returns 403 for non-admin users" do
perform_request_as(regular_user)
expect(response).to have_http_status(:forbidden)
end
end
# Usage
RSpec.describe "GET /api/v1/admin/users", type: :request do
it_behaves_like "requires authentication" do
let(:perform_request_without_auth) { get "/api/v1/admin/users" }
end
it_behaves_like "requires admin role" do
let(:perform_request_as) { |u| get "/api/v1/admin/users", headers: auth_headers_for(u) }
end
endKey Takeaways
- Request specs test the full stack including routing and middleware—use them over controller specs for APIs
- Centralize auth header generation in a helper module; test expired and invalid tokens explicitly
- Build reusable matchers for your JSON format (JSON:API, custom envelopes) so assertions read clearly
- Use shared examples for authorization checks—every protected endpoint should verify 401/403 in two lines
- Test version-specific behavior in version-specific groups; share baseline contract tests with shared examples
- Test webhook signature verification with real HMAC computation, not by stubbing the verification method
- Always assert on
response.bodycontent for error cases—status code alone doesn't verify error format contracts