Rails API Testing with RSpec: Request Specs, Shared Examples, and Swagger
Rails APIs need a different testing approach than HTML applications. There's no browser to drive, no UI interactions — just HTTP requests and JSON responses. RSpec request specs are the right tool: they test the full request/response cycle through Rack middleware without the overhead of a browser.
This guide covers writing request specs, shared examples for DRY API tests, and generating Swagger documentation automatically from your test suite.
Request Specs vs Controller Specs
RSpec provides two ways to test controllers: controller specs and request specs.
Controller specs (older) test the controller in isolation, mocking the request environment. They're fast but miss routing, middleware, and serializer issues.
Request specs (current recommendation) make real Rack requests through the full stack. They catch routing errors, middleware issues, authentication edge cases, and serializer bugs — everything that matters for an API.
Always use request specs for Rails APIs. Controller specs are deprecated in newer Rails projects.
Setup
# Gemfile
group :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "shoulda-matchers"
gem "json_matchers" # JSON schema validation
end# spec/rails_helper.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
endBasic Request Spec Structure
# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users API", type: :request do
describe "GET /api/v1/users/:id" do
context "with valid ID" do
let(:user) { create(:user) }
it "returns the user" do
get api_v1_user_path(user), headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(response.content_type).to include("application/json")
json = response.parsed_body
expect(json["id"]).to eq(user.id)
expect(json["email"]).to eq(user.email)
expect(json).not_to have_key("password_digest") # never expose
end
end
context "with invalid ID" do
it "returns 404" do
get api_v1_user_path(id: 99999), headers: auth_headers(create(:user))
expect(response).to have_http_status(:not_found)
json = response.parsed_body
expect(json["error"]).to eq("User not found")
end
end
context "without authentication" do
it "returns 401" do
get api_v1_user_path(create(:user))
expect(response).to have_http_status(:unauthorized)
end
end
end
endAuthentication Helpers
Extract auth header logic into a shared helper:
# spec/support/auth_helpers.rb
module AuthHelpers
def auth_headers(user)
token = JsonWebToken.encode(user_id: user.id)
{
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json",
"Accept" => "application/json"
}
end
def admin_headers
auth_headers(create(:user, :admin))
end
end
RSpec.configure do |config|
config.include AuthHelpers, type: :request
endFor Devise + JWT:
module AuthHelpers
def auth_headers(user)
post api_v1_sessions_path, params: {
user: { email: user.email, password: "password" }
}.to_json, headers: { "Content-Type" => "application/json" }
{ "Authorization" => response.headers["Authorization"] }
end
endTesting CRUD Endpoints
POST — Create
describe "POST /api/v1/posts" do
let(:user) { create(:user) }
let(:valid_params) do
{
post: {
title: "My First Post",
body: "This is the post content.",
published: true
}
}
end
context "with valid parameters" do
it "creates a post" do
expect {
post api_v1_posts_path,
params: valid_params.to_json,
headers: auth_headers(user)
}.to change(Post, :count).by(1)
expect(response).to have_http_status(:created)
json = response.parsed_body
expect(json["title"]).to eq("My First Post")
expect(json["author"]["id"]).to eq(user.id)
end
end
context "with invalid parameters" do
it "returns validation errors" do
post api_v1_posts_path,
params: { post: { title: "" } }.to_json,
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_entity)
json = response.parsed_body
expect(json["errors"]).to include("Title can't be blank")
end
end
endPATCH — Update
describe "PATCH /api/v1/posts/:id" do
let(:author) { create(:user) }
let(:post_record) { create(:post, user: author) }
it "updates the post" do
patch api_v1_post_path(post_record),
params: { post: { title: "Updated Title" } }.to_json,
headers: auth_headers(author)
expect(response).to have_http_status(:ok)
expect(post_record.reload.title).to eq("Updated Title")
end
it "returns 403 for non-owner" do
other_user = create(:user)
patch api_v1_post_path(post_record),
params: { post: { title: "Hijacked" } }.to_json,
headers: auth_headers(other_user)
expect(response).to have_http_status(:forbidden)
end
endDELETE
describe "DELETE /api/v1/posts/:id" do
let(:author) { create(:user) }
let!(:post_record) { create(:post, user: author) }
it "deletes the post" do
expect {
delete api_v1_post_path(post_record), headers: auth_headers(author)
}.to change(Post, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
endShared Examples
Shared examples eliminate repetition for cross-cutting concerns: authentication, pagination, authorization.
Unauthenticated Request Behavior
# spec/support/shared_examples/authentication.rb
RSpec.shared_examples "requires authentication" do |method, path_proc|
it "returns 401 without token" do
send(method, instance_exec(&path_proc))
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with invalid token" do
send(method, instance_exec(&path_proc),
headers: { "Authorization" => "Bearer invalid-token" })
expect(response).to have_http_status(:unauthorized)
end
it "returns 401 with expired token" do
user = create(:user)
expired_token = JsonWebToken.encode({ user_id: user.id }, expires_in: -1.hour)
send(method, instance_exec(&path_proc),
headers: { "Authorization" => "Bearer #{expired_token}" })
expect(response).to have_http_status(:unauthorized)
end
endUse in specs:
RSpec.describe "Posts API", type: :request do
include_examples "requires authentication", :get, -> { api_v1_posts_path }
describe "GET /api/v1/posts" do
# ... authenticated tests
end
endPagination Behavior
# spec/support/shared_examples/pagination.rb
RSpec.shared_examples "a paginated endpoint" do
it "returns the first page by default" do
json = response.parsed_body
expect(json["meta"]["page"]).to eq(1)
expect(json["meta"]["per_page"]).to eq(20)
expect(json["data"].length).to be <= 20
end
it "supports page parameter" do
request_with_page(2)
json = response.parsed_body
expect(json["meta"]["page"]).to eq(2)
end
it "supports per_page parameter" do
request_with_per_page(5)
json = response.parsed_body
expect(json["data"].length).to be <= 5
expect(json["meta"]["per_page"]).to eq(5)
end
endAdmin-Only Access
RSpec.shared_examples "requires admin role" do
let(:regular_user) { create(:user) }
it "returns 403 for non-admin" do
make_request(headers: auth_headers(regular_user))
expect(response).to have_http_status(:forbidden)
end
it "returns 200 for admin" do
make_request(headers: admin_headers)
expect(response).to have_http_status(:ok)
end
end
# Usage
RSpec.describe "Admin API", type: :request do
describe "GET /api/v1/admin/stats" do
def make_request(headers: {})
get api_v1_admin_stats_path, headers: headers
end
include_examples "requires admin role"
end
endJSON Response Validation
Validate response structure with JSON schemas:
# spec/support/json_schemas/user.json
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"required": ["id", "email", "name", "created_at"],
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"name": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}# In request spec
it "returns a valid user JSON structure" do
get api_v1_user_path(user), headers: auth_headers(user)
expect(response).to match_json_schema("user")
endThe json_matchers gem validates response body against the schema, catching field renames, type changes, and unexpected additions.
Generating Swagger Documentation
rswag generates OpenAPI (Swagger) documentation from your RSpec request specs. Tests double as documentation.
Setup
# Gemfile
gem "rswag-api"
gem "rswag-ui"
gem "rswag-specs", group: :testrails generate rswag:installWriting Documentation Specs
# spec/swagger/posts_spec.rb
require "swagger_helper"
RSpec.describe "Posts API", type: :request do
path "/api/v1/posts" do
get "List posts" do
tags "Posts"
produces "application/json"
security [BearerAuth: []]
parameter name: :page, in: :query, type: :integer, required: false
parameter name: :per_page, in: :query, type: :integer, required: false
response "200", "posts retrieved" do
schema type: :object,
properties: {
data: {
type: :array,
items: { "$ref" => "#/components/schemas/Post" }
},
meta: { "$ref" => "#/components/schemas/PaginationMeta" }
}
let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: create(:user).id)}" }
run_test!
end
response "401", "unauthorized" do
schema "$ref" => "#/components/schemas/Error"
let(:Authorization) { "Bearer invalid" }
run_test!
end
end
post "Create post" do
tags "Posts"
consumes "application/json"
produces "application/json"
security [BearerAuth: []]
parameter name: :post_params, in: :body, schema: {
type: :object,
properties: {
post: {
type: :object,
required: [:title, :body],
properties: {
title: { type: :string },
body: { type: :string }
}
}
}
}
response "201", "post created" do
schema "$ref" => "#/components/schemas/Post"
let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: create(:user).id)}" }
let(:post_params) { { post: { title: "Test", body: "Content" } } }
run_test!
end
response "422", "validation error" do
schema "$ref" => "#/components/schemas/ValidationError"
let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: create(:user).id)}" }
let(:post_params) { { post: { title: "" } } }
run_test!
end
end
end
endGenerate the Swagger File
rake rswag:specs:swaggerizeThis creates swagger/v1/swagger.json (or .yaml). Mount Swagger UI in your routes:
# config/routes.rb
mount Rswag::Ui::Engine => "/api-docs"
mount Rswag::Api::Engine => "/api-docs"Visit /api-docs in development for an interactive API explorer.
Testing Edge Cases
Large Payloads
it "handles large request bodies" do
large_text = "x" * 100_000
post api_v1_posts_path,
params: { post: { title: "Test", body: large_text } }.to_json,
headers: auth_headers(user)
expect(response).to have_http_status(:created)
endContent Type Negotiation
it "rejects non-JSON content type" do
post api_v1_posts_path,
params: "title=test&body=content",
headers: auth_headers(user).merge("Content-Type" => "application/x-www-form-urlencoded")
expect(response).to have_http_status(:unsupported_media_type)
endRate Limiting
it "enforces rate limits" do
user = create(:user)
110.times { get api_v1_posts_path, headers: auth_headers(user) }
expect(response).to have_http_status(:too_many_requests)
expect(response.headers["Retry-After"]).to be_present
endContinuous API Monitoring
RSpec request specs validate your API in CI. But APIs can break in production due to environment differences, third-party service changes, or infrastructure issues. HelpMeTest monitors your API endpoints continuously, verifying response shapes and status codes 24/7 — catching production API failures before your consumers do.
Summary
RSpec request specs test your API through the full Rack stack, catching issues that controller specs miss. Shared examples eliminate boilerplate for authentication, pagination, and authorization checks. JSON schema validation ensures response contracts don't change silently. rswag generates living Swagger documentation directly from your test suite.
Write request specs for every endpoint: happy path, validation failures, authentication errors, and authorization edge cases. Keep shared examples in spec/support/shared_examples/ and load them via rails_helper.rb. Let the documentation generate itself from tests that must pass on every CI run.