Rails API Testing: JSON APIs with Request Specs
Testing a Rails API is one of the highest-leverage testing activities in a Rails application. A well-tested API means consumers — mobile apps, frontend SPAs, third-party integrations — can rely on consistent behavior. Request specs are the right tool for this job.
Why Request Specs for APIs
Rails provides three layers where you could test API behavior: model tests, controller tests, and request specs. Model tests are too narrow — they don't test routing, serialization, or HTTP status codes. Controller tests are deprecated and bypass too much middleware. Request specs test the full HTTP stack: the request comes in through the router, hits your controller, and you get back a real HTTP response.
For API testing, that matters. A bug in a route constraint, a missing render json:, or a broken serializer all fail at the HTTP layer. Request specs catch them.
Basic Request Spec Setup
# spec/requests/articles_spec.rb
require "rails_helper"
RSpec.describe "Articles API", type: :request do
describe "GET /api/v1/articles" do
let!(:articles) { create_list(:article, 3, status: "published") }
it "returns the list of articles" do
get "/api/v1/articles"
expect(response).to have_http_status(:ok)
expect(response.content_type).to match("application/json")
json = JSON.parse(response.body)
expect(json.length).to eq(3)
end
end
endParse the response body with JSON.parse(response.body). The resulting Hash or Array is what you assert against.
It helps to extract the JSON parsing into a helper:
# spec/support/request_helpers.rb
module RequestHelpers
def json_body
JSON.parse(response.body)
end
def json_body_symbolized
JSON.parse(response.body, symbolize_names: true)
end
end
RSpec.configure do |config|
config.include RequestHelpers, type: :request
endNow tests read more cleanly:
expect(json_body["title"]).to eq("My Article")
expect(json_body.dig("author", "name")).to eq("Alice")Testing CRUD Endpoints
A standard REST API has predictable patterns. Test each action explicitly:
RSpec.describe "Articles API", type: :request do
let(:user) { create(:user) }
let(:headers) { auth_headers(user) }
let(:article) { create(:article, author: user) }
describe "GET /api/v1/articles/:id" do
context "when the article exists" do
it "returns the article" do
get "/api/v1/articles/#{article.id}", headers: headers
expect(response).to have_http_status(:ok)
expect(json_body["id"]).to eq(article.id)
expect(json_body["title"]).to eq(article.title)
expect(json_body["status"]).to eq("published")
end
end
context "when the article does not exist" do
it "returns 404" do
get "/api/v1/articles/99999", headers: headers
expect(response).to have_http_status(:not_found)
expect(json_body["error"]).to eq("Article not found")
end
end
end
describe "POST /api/v1/articles" do
let(:valid_params) do
{ article: { title: "New Post", body: "Content here", status: "draft" } }
end
context "with valid params" do
it "creates the article and returns 201" do
expect {
post "/api/v1/articles", params: valid_params, headers: headers
}.to change(Article, :count).by(1)
expect(response).to have_http_status(:created)
expect(json_body["title"]).to eq("New Post")
expect(json_body["author"]["id"]).to eq(user.id)
end
end
context "with invalid params" do
it "returns 422 with error details" do
post "/api/v1/articles",
params: { article: { title: "" } },
headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json_body["errors"]["title"]).to include("can't be blank")
end
end
end
describe "PATCH /api/v1/articles/:id" do
it "updates the article" do
patch "/api/v1/articles/#{article.id}",
params: { article: { title: "Updated Title" } },
headers: headers
expect(response).to have_http_status(:ok)
expect(json_body["title"]).to eq("Updated Title")
expect(article.reload.title).to eq("Updated Title")
end
end
describe "DELETE /api/v1/articles/:id" do
it "deletes the article" do
delete "/api/v1/articles/#{article.id}", headers: headers
expect(response).to have_http_status(:no_content)
expect(Article.find_by(id: article.id)).to be_nil
end
end
endAlways assert both the HTTP response and the database state for mutations. The controller might return 200 while failing to actually save — you want tests that catch that.
Authentication Headers
Most APIs use token-based authentication. Pass tokens via headers:
# spec/support/authentication_helpers.rb
module AuthenticationHelpers
def auth_headers(user)
token = JsonWebToken.encode({ user_id: user.id }, 24.hours.from_now)
{
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json",
"Accept" => "application/json"
}
end
end
RSpec.configure do |config|
config.include AuthenticationHelpers, type: :request
endFor Devise with token auth:
module AuthenticationHelpers
def auth_headers(user)
user.create_new_auth_token
end
endTest authentication boundaries explicitly:
RSpec.describe "Articles API", type: :request do
describe "authentication" do
it "returns 401 without a token" do
get "/api/v1/articles"
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with an expired token" do
expired_token = JsonWebToken.encode({ user_id: 1 }, 1.hour.ago)
get "/api/v1/articles", headers: { "Authorization" => "Bearer #{expired_token}" }
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with a malformed token" do
get "/api/v1/articles", headers: { "Authorization" => "Bearer not-a-token" }
expect(response).to have_http_status(:unauthorized)
end
end
endShared Contexts
Authentication setup is repetitive. Extract it into shared contexts:
# spec/support/shared_contexts/api_authentication.rb
RSpec.shared_context "authenticated as user" do
let(:user) { create(:user) }
let(:headers) { auth_headers(user) }
end
RSpec.shared_context "authenticated as admin" do
let(:admin) { create(:user, :admin) }
let(:headers) { auth_headers(admin) }
endUse them in specs:
RSpec.describe "Articles API", type: :request do
include_context "authenticated as user"
describe "POST /api/v1/articles" do
it "creates an article as the current user" do
post "/api/v1/articles",
params: { article: attributes_for(:article) },
headers: headers
expect(json_body["author"]["id"]).to eq(user.id)
end
end
endFor authorization tests, you often need to check what one user can do to another user's resources:
RSpec.describe "Article ownership", type: :request do
include_context "authenticated as user"
let(:other_user) { create(:user) }
let(:other_article) { create(:article, author: other_user) }
it "prevents editing another user's article" do
patch "/api/v1/articles/#{other_article.id}",
params: { article: { title: "Hijacked" } },
headers: headers
expect(response).to have_http_status(:forbidden)
expect(other_article.reload.title).not_to eq("Hijacked")
end
endTesting Pagination
APIs that return collections typically support pagination. Test the boundaries:
RSpec.describe "Articles API", type: :request do
include_context "authenticated as user"
describe "GET /api/v1/articles" do
before { create_list(:article, 25, status: "published") }
it "paginates results" do
get "/api/v1/articles?page=1&per_page=10", headers: headers
expect(response).to have_http_status(:ok)
expect(json_body["articles"].length).to eq(10)
expect(json_body["meta"]["total_count"]).to eq(25)
expect(json_body["meta"]["total_pages"]).to eq(3)
expect(json_body["meta"]["current_page"]).to eq(1)
end
it "returns the second page" do
get "/api/v1/articles?page=2&per_page=10", headers: headers
expect(json_body["articles"].length).to eq(10)
expect(json_body["meta"]["current_page"]).to eq(2)
end
it "returns a partial last page" do
get "/api/v1/articles?page=3&per_page=10", headers: headers
expect(json_body["articles"].length).to eq(5)
expect(json_body["meta"]["current_page"]).to eq(3)
end
end
endTesting Filters and Search
RSpec.describe "Articles API filtering", type: :request do
include_context "authenticated as user"
let!(:ruby_article) { create(:article, title: "Ruby Testing", tag_list: "ruby") }
let!(:rails_article) { create(:article, title: "Rails Guide", tag_list: "rails") }
let!(:draft_article) { create(:article, status: "draft") }
it "filters by status" do
get "/api/v1/articles?status=published", headers: headers
titles = json_body["articles"].map { |a| a["title"] }
expect(titles).to include("Ruby Testing", "Rails Guide")
expect(titles).not_to include(draft_article.title)
end
it "filters by tag" do
get "/api/v1/articles?tag=ruby", headers: headers
expect(json_body["articles"].length).to eq(1)
expect(json_body["articles"].first["title"]).to eq("Ruby Testing")
end
it "searches by title" do
get "/api/v1/articles?q=Ruby", headers: headers
expect(json_body["articles"].length).to eq(1)
expect(json_body["articles"].first["title"]).to eq("Ruby Testing")
end
endTesting File Uploads
For APIs that accept file uploads:
RSpec.describe "Avatar upload", type: :request do
include_context "authenticated as user"
it "uploads a profile avatar" do
file = fixture_file_upload("spec/fixtures/files/avatar.png", "image/png")
patch "/api/v1/users/#{user.id}",
params: { user: { avatar: file } },
headers: headers.except("Content-Type") # let Rails set multipart content-type
expect(response).to have_http_status(:ok)
expect(json_body["avatar_url"]).to be_present
expect(user.reload.avatar).to be_attached
end
endJSON Schema Validation
For critical APIs, validate response shapes with JSON schema:
gem "json_matchers"// spec/support/api/schemas/article.json
{
"type": "object",
"required": ["id", "title", "status", "author"],
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"status": { "type": "string", "enum": ["draft", "published", "archived"] },
"author": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
}
}RSpec.configure do |config|
config.include JsonMatchers
end
it "returns a valid article schema" do
get "/api/v1/articles/#{article.id}", headers: headers
expect(response).to match_json_schema("article")
endSchema validation catches when a field is removed from a serializer or changes type — the kind of breaking change that doesn't show up in unit tests.
Testing Webhooks and Callbacks
For APIs that receive webhooks:
RSpec.describe "Stripe webhooks", type: :request do
def webhook_headers(payload)
timestamp = Time.now.to_i
signature = Stripe::Webhook::Signature.compute_signature(
timestamp, payload, ENV["STRIPE_WEBHOOK_SECRET"]
)
{
"Stripe-Signature" => "t=#{timestamp},v1=#{signature}",
"Content-Type" => "application/json"
}
end
it "handles payment_intent.succeeded" do
payload = {
type: "payment_intent.succeeded",
data: { object: { id: "pi_test123", amount: 5000, currency: "usd" } }
}.to_json
post "/webhooks/stripe", params: payload, headers: webhook_headers(payload)
expect(response).to have_http_status(:ok)
expect(Order.last.status).to eq("paid")
end
it "rejects requests with invalid signatures" do
post "/webhooks/stripe",
params: { type: "payment_intent.succeeded" }.to_json,
headers: { "Stripe-Signature" => "invalid", "Content-Type" => "application/json" }
expect(response).to have_http_status(:bad_request)
end
endVersioning
Test that version routing works correctly:
RSpec.describe "API versioning", type: :request do
it "routes v1 requests correctly" do
get "/api/v1/articles"
expect(response).not_to have_http_status(:not_found)
end
it "returns 404 for unsupported versions" do
get "/api/v99/articles"
expect(response).to have_http_status(:not_found)
end
endRequest specs give you confidence in your Rails API's behavior across the full HTTP stack. For production monitoring of your API endpoints — catching failures that only appear with real traffic and real data — HelpMeTest provides continuous end-to-end test runs that complement your local request spec suite.