Rails API Testing Patterns: Request Specs, JSON Assertions, and Authentication

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
end

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

Usage:

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
end

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

JSON 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
end
RSpec.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
end

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

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

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

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

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

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

Key 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.body content for error cases—status code alone doesn't verify error format contracts

Read more

Start now free