rspec-rest
rspec-rest is a Ruby gem for behavior-first REST API specs built on top of
RSpec and Rack::Test.
It focuses on:
- concise request DSL
- JSON-first expectations
- capture/reuse of response values
- high-signal failure output with request/response context
- auto-generated
curlreproduction commands on failures
Installation
When published:
# Gemfile
gem "rspec-rest"Until then, use GitHub:
# Gemfile
gem "rspec-rest", git: "https://github.com/llwebconsulting/rspec-rest.git"Then:
bundle installQuick Start
RSpec.describe "Users API" do
include RSpec::Rest
api do
app Rails.application
base_path "/v1"
base_headers "Accept" => "application/json"
default_format :json
base_url "http://localhost:3000" # used for failure-time curl reproduction
end
resource "/users" do
get path: "/" do
expect_status 200
expect_header "Content-Type", "application/json"
expect_json array_of(hash_including("id" => integer, "email" => string))
end
post path: "/" do
json "email" => "carl@example.com", "name" => "Carl"
expect_status 201
capture :user_id, "$.id"
end
end
endBefore and After (Rack::Test to rspec-rest)
The first two examples below test the same behavior in two styles.
Before (Rack::Test + manual response parsing):
RSpec.describe MyApp::V1::Posts, type: :request do
include Rack::Test::Methods
def app
MyApp::Base
end
let(:auth_token) { "test-token" }
let!(:posts) { create_list(:post, 3).sort_by(&:created_at).reverse }
before { header "Authorization", "Bearer #{auth_token}" }
it "returns posts page 1" do
get "/api/v1/posts", { page: 1, per_page: 10 }
payload = JSON.parse(last_response.body)
expect(last_response.status).to eq(200)
expect(payload.size).to eq(3)
expect(payload.first["id"]).to eq(posts.first.id)
expect(payload.first["author"]["id"]).to eq(posts.first.author.id)
end
endAfter (rspec-rest DSL):
RSpec.describe "Posts API" do
include RSpec::Rest
let(:auth_token) { "test-token" }
let!(:posts) { create_list(:post, 3).sort_by(&:created_at).reverse }
api do
app MyApp::Base
base_path "/api/v1"
default_format :json
end
resource "/posts" do
with_auth auth_token
get path: "/", description: "returns posts page 1" do
query page: 1, per_page: 10
expect_status 200
expect_json array_of(hash_including("id" => integer, "author" => hash_including("id" => integer)))
expect_json_first hash_including("id" => posts.first.id)
expect_json_item(0) { |item| expect(item["author"]["id"]).to eq(posts.first.author.id) }
end
end
endWhat improves in the apples-to-apples rewrite:
- Request setup is declarative (
api,resource,with_auth,query). - JSON assertions read closer to the business intent (
expect_json_first,expect_json_item). - Failure output includes request/response context and a reproducible
curl.
Beyond the baseline rewrite, rspec-rest can also express additional API concerns
with concise helpers:
RSpec.describe "Posts API" do
include RSpec::Rest
let(:auth_token) { "test-token" }
api do
app MyApp::Base
base_path "/api/v1"
default_format :json
end
contract :post_summary do
hash_including("id" => integer, "author" => hash_including("id" => integer))
end
resource "/posts" do
with_auth auth_token
get path: "/" do
query page: 1, per_page: 10
expect_status 200
expect_json array_of(contract(:post_summary))
expect_page_size 10
end
get path: "/{id}" do
path_params id: 999_999
expect_error status: 404, message: "Post not found"
end
end
resource "/uploads" do
with_auth auth_token
post path: "/" do
multipart!
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
expect_status 201
expect_json hash_including("filename" => "sample_upload.txt")
end
end
endAPI Config (api)
api defines shared runtime configuration for a spec group.
api do
app Rails.application
base_path "/v1"
base_headers "Accept" => "application/json"
default_format :json
base_url "http://localhost:3000"
redact_headers ["Authorization", "Cookie", "Set-Cookie"]
endSupported config:
-
app: Rack app (required) -
base_path: base request path prefix -
base_headers: default headers merged into every request -
default_format: set to:jsonto defaultAccept: application/json -
base_url: used for generated curl commands (http://example.orgdefault) -
redact_headers: headers redacted in failure output and curl
Resources And Verbs
resource "/users" do ... end-
get,post,put,patch,delete- preferred form:
get(path: "/users", description: "...") { ... } - legacy positional path form
get("/users", description: "...")is deprecated and will be removed in1.0. It is deprecated to avoidRails/HttpPositionalArgumentsfalse-positives in RuboCop. - legacy positional description form
get "/users", "description"is deprecated and will be removed in1.0.
- preferred form:
Resource paths are composable and support placeholders:
resource "/users" do
resource "/{id}/posts" do
get path: "/" do
path_params id: 1
expect_status 404
end
end
endExample with an explicit behavior name:
resource "/users" do
get path: "/", description: "returns public users for authenticated client" do
expect_status 200
end
endRSpec example output uses the composed full route (including base_path) and appends the optional description, for example:
GET /v1/users - returns public users for authenticated client
Note: Example names (including base_path) are composed when the verb macro is evaluated. To ensure the example names include base_path, declare your api (and its base_path) before defining resource blocks and their verbs. If you configure api/base_path afterward, requests will use the configured base_path, but the previously defined example names will not reflect it.
RuboCop Compatibility
rspec-rest verbs (get, post, etc.) define examples via DSL macros, and can trigger
false-positives in some RuboCop cops:
-
Rails/HttpPositionalArguments: use keyword paths (path:) and keyword descriptions (description:), not positional arguments. -
RSpec/EmptyExampleGroup: acontextthat only containsresource+ verb DSL may be flagged as empty. Recommended mitigation is scoped configuration for files that userspec-rest:
# .rubocop.yml
RSpec/EmptyExampleGroup:
Exclude:
- "spec/api/rest/**/*"If you only have a few affected groups, use an inline disable around the DSL group:
# rubocop:disable RSpec/EmptyExampleGroup
context "when authenticated" do
resource "/posts" do
get path: "/", description: "returns posts" do
expect_status 200
end
end
end
# rubocop:enable RSpec/EmptyExampleGroupPrefer scoped exclusions/disables over broad project-wide disables so non-DSL specs keep full lint coverage.
We are also considering a dedicated RuboCop extension for rspec-rest (for example,
rubocop-rspec-rest) to reduce manual configuration over time. Until then, the
scoped patterns above are the recommended approach.
Shared Request Presets
Define shared request defaults at group/resource scope:
with_headers(hash)with_query(hash)-
with_auth(token)(setsAuthorization: Bearer <token>)
Use presets when your API requires repeated request context across many endpoints, for example auth headers, locale/tenant query params, client/app version headers, or other codebase-specific defaults.
Nested resources inherit presets, and request-level builders (header, query, bearer) can override them.
Typical pattern:
- set broad defaults at top-level (
with_query,with_headers) - narrow defaults at resource scope (
with_auth, resource-specific headers) - override per request only when behavior differs
with_query locale: "en"
with_headers "X-Tenant-Id" => "tenant-123"
resource "/posts" do
with_auth ENV.fetch("API_TOKEN", "token-123")
with_headers "X-Client" => "mobile"
get path: "/" do
query page: 2
expect_status 200
end
get path: "/admin" do
header "X-Client", "internal-tool" # request-level override
query locale: "fr" # request-level override
expect_status 200
end
endRequest Builders
Inside verb blocks:
header(key, value)headers(hash)bearer(token)unauthenticated!query(hash)json(hash_or_string)multipart!file(param_key, file_or_path, content_type: nil, filename: nil)path_params(hash)
Example:
post path: "/" do
headers "X-Trace-Id" => "abc-123"
bearer "token-123"
query include_details: "true"
json "email" => "dev@example.com", "name" => "Dev"
expect_status 201
endMultipart upload example:
post path: "/uploads" do
multipart!
file :file, Rails.root.join("spec/fixtures/files/sample_upload.txt"), content_type: "text/plain"
expect_status 201
expect_json hash_including("filename" => "sample_upload.txt")
endExpectations
Available expectation helpers:
expect_status(code)expect_header(key, value_or_regex)expect_json(expected = nil, &block)-
contract(name)(lookup helper for reusable JSON contracts) -
contract_with(name, overrides)(contract lookup with value overrides) -
expect_json_contract(name)(deprecated; usecontract(name)) expect_json_at(selector, expected = nil, &block)expect_body_includes(fragment)-
expect_body_matches(pattern)(StringorRegexp) expect_json_first(expected = nil, &block)expect_json_item(index, expected = nil, &block)expect_json_last(expected = nil, &block)expect_error(status:, message: nil, includes: nil, field: nil, key: "error")expect_page_size(size, selector: "$")expect_max_page_size(max, selector: "$")expect_ids_in_order(ids, selector: "$[*].id")
expect_json supports:
- matcher mode:
expect_json hash_including("id" => integer)
- equality mode:
expect_json("id" => 1, "email" => "jane@example.com", "name" => "Jane")
- block mode:
expect_json { |payload| expect(payload["id"]).to integer }
expect_json_at supports the same matcher/equality/block modes against a selected path:
- matcher mode:
expect_json_at "$.user.id", integer
- equality mode:
expect_json_at "$.user.email", "jane@example.com"
- block mode:
expect_json_at "$.items[0]" { |item| expect(item["id"]).to integer }
- top-level shorthand:
expect_json_at :message, "Not found"expect_json_at "message", "Not found"
For common array-item checks, use Ruby-style helpers instead of selector strings:
expect_json_first(...)expect_json_item(index, ...)expect_json_last(...)
expect_json_first hash_including("id" => integer)
expect_json_item 2, hash_including("name" => "Third")
expect_json_last { |item| expect(item["id"]).to integer }Raw body assertions for text/non-JSON endpoints:
get path: "/bad_json" do
expect_status 200
expect_body_includes "not json"
expect_body_matches(/this is not json/)
endexpect_error is a convenience helper for common API error payload assertions:
get path: "/{id}" do
path_params id: 999
expect_error status: 404, message: "Post not found"
endPagination helpers:
get path: "/" do
query page: 2, per_page: 10
expect_status 200
expect_page_size 10
expect_max_page_size 20
expect_ids_in_order [30, 29, 28, 27, 26, 25, 24, 23, 22, 21]
endLightweight Contracts
A contract is a named, reusable JSON expectation (usually a response shape matcher).
Define it once in your spec group, then apply it anywhere with contract(:name).
contract :post_summary do
hash_including(
"id" => integer,
"title" => string,
"author" => hash_including("id" => integer)
)
end
get path: "/" do
expect_status 200
expect_json array_of(contract(:post_summary))
expect_json array_of(contract_with(:post_summary, id: 1, title: "My Title", author: { id: 1 }))
endUse contract_with when you want the base contract shape/types plus specific values
for selected keys. Override keys must exist in the contract definition.
JSON type helpers:
integerstringbooleanarray_of(matcher)hash_including(...)
Captures
Capture response values and reuse them later in the same example:
capture(:name, selector)get(:name)
Selector syntax (minimal JSON selector):
$.a.b$.items[0].id
Example:
post path: "/" do
json "email" => "flow@example.com", "name" => "Flow"
expect_status 201
capture :user_id, "$.id"
endFailure Output and curl Reproduction
When an expectation fails, output includes:
- request method/path
- request headers/body
- response status/headers/body
- generated
curlcommand
Sensitive headers are redacted by default and can be customized via
redact_headers.
Example (truncated):
expected: 201
got: 422
Request:
POST /api/v1/posts
Headers:
Accept: application/json
Authorization: [REDACTED]
Content-Type: application/json
Body:
{
"title": "",
"body": "Example"
}
Response:
Status: 422
Headers:
Content-Type: application/json
Body:
{
"error": "Validation failed",
"details": {
"title": ["can't be blank"]
}
}
Reproduce with:
curl -X POST 'http://localhost:3000/api/v1/posts' -H 'Accept: application/json' -H "Authorization: Bearer $API_AUTH_TOKEN" -H 'Content-Type: application/json' -d '{"title":"","body":"Example"}'
Before running an authenticated command, set your token:
export API_AUTH_TOKEN="your_token_here"Then paste the generated curl command directly in your terminal for fast manual debugging.
Contributing
Contributions are welcome.
Recommended workflow:
- Fork the repository on GitHub.
- Clone your fork locally.
- Create a feature branch from
main. - Make your changes with tests/docs as needed.
- Run quality checks locally:
bundle exec rspecbundle exec rubocop
- Commit and push your branch to your fork.
- Open a Pull Request from your fork to this repository.
Pull request guidelines:
- Keep changes focused and include context in the PR description.
- Add or update specs for behavior changes.
- Update README/CHANGELOG when public behavior changes.
- Ensure CI is green before requesting final review.
Reporting issues and feature ideas:
- Use GitHub Issues and choose the appropriate template:
- Bug report for incorrect behavior (include expected vs actual behavior and repro steps).
- Feature request for enhancement ideas.
- Feature suggestions are appreciated and encouraged.
- The fastest path to getting a feature implemented is to open a pull request with the proposed change and tests.
Development
bundle install
bundle exec rspec
bundle exec rubocopNamespace
Gem name: rspec-rest
Ruby namespace: RSpec::Rest
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.