Testing Rails APIs with RSpec — My Practical Approach

by Eric Hanson, Backend Developer at Clean Systems Consulting

What layer to test and why

Rails gives you three places to test an API endpoint: controller specs, request specs, and integration tests. Controller specs (the old standard) test the controller in isolation — they mock the request and don't exercise routing, middleware, or serializers. Request specs make a real HTTP request through the full stack. Integration tests are request specs with database state assertions added.

Controller specs are mostly obsolete for API testing. The value of an API test is verifying that the HTTP contract — the route, the authentication, the serialized response shape, the status code — works correctly end-to-end. A controller spec that mocks the request and stubs the serializer doesn't verify any of that.

Request specs, backed by a database, are the right tool. They run fast enough for a reasonable suite and test what actually matters: what the client sends, what the server returns.

The base setup

# spec/support/api_helpers.rb
module ApiHelpers
  def json_response
    JSON.parse(response.body, symbolize_names: true)
  end

  def auth_headers(user)
    token = Auth::TokenService.issue(user_id: user.id)
    { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
  end

  def post_json(path, params: {}, headers: {})
    post path, params: params.to_json, headers: headers.merge("Content-Type" => "application/json")
  end

  def patch_json(path, params: {}, headers: {})
    patch path, params: params.to_json, headers: headers.merge("Content-Type" => "application/json")
  end
end

RSpec.configure do |config|
  config.include ApiHelpers, type: :request
end

json_response parses the body once and returns a symbolized hash — no repeated JSON.parse calls in every test. auth_headers issues a real token using the same token service the application uses. post_json and patch_json handle the content-type header consistently so individual tests don't repeat it.

A complete request spec

RSpec.describe "POST /api/v1/orders", type: :request do
  let(:user) { create(:user) }
  let(:cart) { create(:cart, :with_items, user: user) }

  describe "creating an order" do
    context "with valid params and authentication" do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          instance_double(PaymentGateway::Charge, ok?: true, id: "ch_test_123")
        )
      end

      it "returns 201 with the order data" do
        post_json(
          "/api/v1/orders",
          params:  { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
          headers: auth_headers(user)
        )

        expect(response).to have_http_status(:created)
        expect(json_response[:data]).to include(
          status:   "pending",
          currency: "USD"
        )
        expect(json_response[:data][:id]).to be_present
      end

      it "creates the order in the database" do
        expect {
          post_json(
            "/api/v1/orders",
            params:  { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
            headers: auth_headers(user)
          )
        }.to change(Order, :count).by(1)
      end
    end

    context "without authentication" do
      it "returns 401" do
        post_json "/api/v1/orders", params: { order: { cart_id: cart.id } }
        expect(response).to have_http_status(:unauthorized)
      end

      it "returns a machine-readable error code" do
        post_json "/api/v1/orders", params: { order: { cart_id: cart.id } }
        expect(json_response[:errors].first[:code]).to eq("token_invalid")
      end
    end

    context "when payment is declined" do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          instance_double(PaymentGateway::Charge, ok?: false, decline_reason: "insufficient_funds")
        )
      end

      it "returns 422" do
        post_json(
          "/api/v1/orders",
          params:  { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
          headers: auth_headers(user)
        )
        expect(response).to have_http_status(:unprocessable_entity)
      end

      it "does not create an order" do
        expect {
          post_json(
            "/api/v1/orders",
            params:  { order: { cart_id: cart.id, payment_method: "pm_test_visa" } },
            headers: auth_headers(user)
          )
        }.not_to change(Order, :count)
      end
    end

    context "when cart belongs to another user" do
      let(:other_cart) { create(:cart, :with_items) }

      it "returns 403" do
        post_json(
          "/api/v1/orders",
          params:  { order: { cart_id: other_cart.id, payment_method: "pm_test_visa" } },
          headers: auth_headers(user)
        )
        expect(response).to have_http_status(:forbidden)
      end
    end
  end
end

Several things to observe in this structure:

The payment gateway is stubbed because it's an external service. Hitting real external services in request specs makes tests slow, non-deterministic, and dependent on network availability. The stub uses instance_double — verified against the real class interface.

Each it block tests one thing. The status code test and the database state test are separate. When either fails, the failure message is precise.

Authorization failure gets its own context block with both the status code and the error code tested. Clients integrate against error codes, not just status codes.

What to assert in a request spec

The assertions worth making in every request spec:

HTTP status code — always. have_http_status(:created) over have_http_status(201) for readability. Both are equivalent; the symbol form documents intent.

Response shape for success — assert the presence and type of fields the client depends on, not the exact value of every field. expect(json_response[:data][:id]).to be_present is more useful than expect(json_response[:data][:id]).to eq(order.id) when the order hasn't been created yet.

Error codes for failureexpect(json_response[:errors].first[:code]).to eq("token_invalid"). Clients branch on these codes. Test them explicitly.

Database state changes — use change(Model, :count) for creation and deletion. Don't query the database to find the created record and assert its attributes in a request spec — that's testing the service object's behavior, which belongs in service object specs.

What not to assert:

Exact timestampscreated_at, updated_at values are correct by definition if the record was created. Asserting their exact value makes tests fragile to timing.

Every field in the response — testing that a response includes 30 fields is brittle maintenance overhead. Test the fields the client actually uses; let schema validation cover completeness.

Internal implementation details — which service object was called, which method was invoked on which model. Request specs test the HTTP contract. Unit specs test the implementation.

Shared examples for common API behaviors

Authentication failure, authorization failure, and not-found responses follow the same pattern across every endpoint. Shared examples eliminate the repetition:

# spec/support/shared_examples/api_authentication.rb
RSpec.shared_examples "requires authentication" do
  it "returns 401 without a token" do
    request_without_auth
    expect(response).to have_http_status(:unauthorized)
  end

  it "returns a token_invalid error code" do
    request_without_auth
    expect(json_response[:errors].first[:code]).to eq("token_invalid")
  end
end

RSpec.shared_examples "returns not found" do
  it "returns 404" do
    expect(response).to have_http_status(:not_found)
  end

  it "returns a not_found error code" do
    expect(json_response[:errors].first[:code]).to eq("not_found")
  end
end

Usage:

RSpec.describe "GET /api/v1/orders/:id", type: :request do
  context "without authentication" do
    let(:request_without_auth) { get "/api/v1/orders/1" }
    it_behaves_like "requires authentication"
  end

  context "when order does not exist" do
    before { get "/api/v1/orders/99999", headers: auth_headers(user) }
    it_behaves_like "returns not found"
  end
end

The shared example defines the behavior; the context defines the setup. Every endpoint that requires authentication gets the same two assertions without rewriting them.

Pagination testing

Cursor-based pagination requires testing the cursor mechanics, not just the data:

RSpec.describe "GET /api/v1/orders", type: :request do
  let(:user) { create(:user) }

  before { create_list(:order, 5, user: user) }

  it "returns the first page with a next cursor" do
    get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)

    expect(response).to have_http_status(:ok)
    expect(json_response[:data].length).to eq(3)
    expect(json_response[:meta][:has_more]).to be true
    expect(json_response[:meta][:next_cursor]).to be_present
  end

  it "returns the second page using the cursor" do
    get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)
    cursor = json_response[:meta][:next_cursor]

    get "/api/v1/orders", params: { per_page: 3, cursor: cursor }, headers: auth_headers(user)

    expect(json_response[:data].length).to eq(2)
    expect(json_response[:meta][:has_more]).to be false
  end

  it "does not return duplicate records across pages" do
    get "/api/v1/orders", params: { per_page: 3 }, headers: auth_headers(user)
    first_page_ids = json_response[:data].map { |o| o[:id] }
    cursor = json_response[:meta][:next_cursor]

    get "/api/v1/orders", params: { per_page: 3, cursor: cursor }, headers: auth_headers(user)
    second_page_ids = json_response[:data].map { |o| o[:id] }

    expect(first_page_ids & second_page_ids).to be_empty
  end
end

The no-duplicate-records test is the one most teams skip and the one that catches cursor implementation bugs. Two sequential requests should never return the same record. Test it explicitly.

The test suite organization that survives growth

As the API grows, request specs organized by endpoint become hard to navigate. Organize by resource with consistent file naming:

spec/
  requests/
    api/
      v1/
        orders/
          create_spec.rb
          index_spec.rb
          show_spec.rb
          update_spec.rb
        users/
          show_spec.rb
          update_spec.rb

One file per action, named for the HTTP verb. create_spec.rb tests POST. update_spec.rb tests PATCH. A developer looking for the test for order creation knows exactly where to find it.

Tags make it possible to run subsets:

RSpec.describe "POST /api/v1/orders", type: :request, tags: [:orders, :create] do
rspec --tag orders         # run all order specs
rspec --tag create         # run all create action specs
rspec spec/requests/api    # run all request specs

Combined with CI parallelization — splitting the request spec suite across workers by file — a request spec suite with 500 specs runs in under two minutes on most CI configurations. The file-per-action organization makes even splitting natural: each file is an independent unit with no cross-file dependencies.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Hollywood, Gaming, and Startups All Want the Same LA Backend Developers

Los Angeles has three of the most technically demanding industries in the world competing for backend talent. Startups are usually last in line.

Read more

N+1 Queries in Rails — How I Find and Fix Them for Good

N+1 queries are the most common Rails performance problem and the most consistently underestimated. Here is a systematic approach to finding them, fixing them correctly, and preventing them from coming back.

Read more

Seattle Has Amazon and Microsoft. Everyone Else Competes for the Same Engineers — or Goes Remote

You found a backend engineer who loved your product, aced the technical screen, and seemed genuinely excited. Then Amazon matched with a $50K signing bonus.

Read more

The Research Triangle Produces Top Backend Talent That Startups Rarely Get to Hire

NC State, Duke, and UNC feed one of the strongest engineering pipelines in the Southeast. Most of it flows somewhere other than your startup.

Read more