Testing APIs with rspec and json-schema

The json-schema landscape in ruby is a bit unusual. The json_matchers gem has integration with RSpec/Minitest and good support for json-schema features (including resolving $refs), however. It uses the json-schema gem underneath, and inherits its featureset. This library doesn’t appear to be well maintained, and only supports json-schema draft-05. This is a good compromise (for my use case) but another option may be more suitable if draft-05 is too old.

The following steps assume you’re building a rails app, and testing it with rspec and vcr.

  1. Install json_matchers in the :test gem group

     group :test do
       gem "json_matchers"
     end
    
  2. Configure the integration (in your spec_helper.rb or spec/support/ directory)

     require "json_matchers/rspec"
     JsonMatchers.schema_root = "spec/schemas"
    
  3. Create a schema, e.g. spec/schemas/v2/error.json:

     {
       "$schema": "https://json-schema.org/draft-06/schema#",
       "title": "API v2: Error Response",
       "type": "object",
       "additionalProperties": false,
       "additionalItems": false,
       "required": ["status", "message"],
       "properties": {
         "status": { "type": "string" },
         "message": { "type": "string" }
       }
     }
    
  4. Check the response matches the schema

     # spec/requests/v2/postcodes_controller_spec.rb
     describe V2::PostcodesController, :vcr do
       context "with an invalid postcode" do
         before { get "/v2/connectivity/for_postcode/not_a_postcode" }
    
         it { is_expected.to have_http_status :bad_request }
         it { is_expected.to match_json_schema("v2/error") }
       end
     end
    

The alternative–and a strategy you probably still need if you want to make expectations about the exact response coming back from the system under test–is to take advantage of rspec composable matchers. For example:

# spec/requests/v1/products_controller_spec.rb
describe V1::ProductsController do
  it "returns a list of product names, codes and categories" do
    get "/v1/products"

    products = response.parsed_body.fetch("products", [])
    expect(products).to all(a_hash_including(
      "name" => a_string_matching(/^[\dG]+\/[\dG]+$/),
      "code" => a_string_matching(/FIBRE_\d+_\d+/),
      "category" => kind_of(String),
    ))
  end
end

The advantages of using json-schema here are several: