Rails API Testing with RSpec: Request Specs, Shared Examples, and Swagger

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
end

Basic 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
end

Authentication 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
end

For 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
end

Testing 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
end

PATCH — 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
end

DELETE

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
end

Shared 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
end

Use 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
end

Pagination 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
end

Admin-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
end

JSON 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")
end

The 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: :test
rails generate rswag:install

Writing 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
end

Generate the Swagger File

rake rswag:specs:swaggerize

This 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)
end

Content 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)
end

Rate 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
end

Continuous 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.

Read more