Make your JSON API tests clean with linter
… and check why 5600+ Rails engineers read also this
Make your JSON API tests clean with linter
Recently, one of our customers requested that mobile devices should communicate with backend via JSON API. We started implementing an endpoint for registering customers.
We used JSON Schema describing JSON API as a part of custom RSpec matcher. To be sure that both request and response body are following the schema.
RSpec::Matchers.define :be_valid_jsonapi_document do
def schema_path
Rails.root.join("spec/support/schema.json").to_s
end
match do |document|
JSON::Validator.validate(schema_path, document)
end
failure_message do |document|
JSON::Validator.fully_validate(schema_path, document).join("\n")
end
end
As you might notice, json-schema
gem was used to validate the schema, but that’s an implementation detail. Let’s take a look at the test:
RSpec.describe "Customers endpoint", type: :request do
specify "registering customer" do
json_data = JSON.dump({
data: {
id: "de47043c-bd1a-4592-9601-a68ad0d0ea89",
type: "customers",
attributes: {
email: "joe@example.com",
password: "Foo123Bar",
toc_agreement: true,
marketing_agreement: true,
newsletter_agreement: true,
}
}
})
post "/users", params: json_data, headers: { "CONTENT_TYPE" => "application/vnd.api+json" }
expect(JSON.parse(json_data)).to be_valid_jsonapi_document
expect(request.content_type).to eq("application/vnd.api+json")
expect(response).to have_http_status(201)
expect(response.content_type).to eq("application/vnd.api+json")
parsed_body = JSON.parse(response.body)
expect(parsed_body).to be_valid_jsonapi_document
expect(parsed_body["data"]["id"]).to eq("de47043c-bd1a-4592-9601-a68ad0d0ea89")
expect(parsed_body["data"]["type"]).to eq("customers")
end
end
We’re posting data regarding customer like email, password, and some agreements. Then we validate if request and response meet our expectations. Especially if they are valid with JSON Schema.
This test is a bit cluttered, don’t you think? Wouldn’t it be better to make schema validations more transparent and focus on important things? By important I assume business information, not infrastructural things like proper headers or following the schema.
The spec above is a request type of spec. Request specs provide a thin wrapper around Rails’ integration tests, and are designed to drive behaviour through the full stack, including routing (provided by Rails) and without stubbing (that’s up to you). If it’s a full stack test, probably we can get access to the Rails app via app
method. #<UsersApp::Application:0x007fc86503cc7... >
. Yes, we can.
Let’s write a middleware which wraps the Rails app instantiated in a spec example to validate request & response. We can do this since Rails application is a Rack object. According to Rack documentation, it provides a minimal interface between webservers that support Ruby and Ruby frameworks. To use Rack, provide an “app”: an object that responds to the call method, taking the environment hash as a parameter, and returning an Array with three elements:
- The HTTP response code - A Hash of headers - The response body, which must respond to each
Linter implementation:
class JsonApiLint
class InvalidContentType < StandardError
def initialize(content_type)
super(<<~EOS)
expected: Content-Type: application/vnd.api+json
got: Content-Type: #{content_type}
EOS
end
end
class InvalidDocument < StandardError
def initialize(document)
super(JSON::Validator.fully_validate(Rails.root.join("spec/support/schema.json").to_s, document).join("\n"))
end
end
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
status, headers, body = @app.call(env)
response = Rack::Response.new(body, status, headers)
validate_request(request)
validate_response(response)
response
end
private
def validate_response(response)
raise InvalidContentType.new(response.content_type) unless match_content_type(response.content_type)
document = JSON.parse(response.body.dup.join)
raise InvalidDocument.new(document) unless valid_schema(document)
end
def validate_request(request)
raise InvalidContentType.new(request.content_type) unless match_content_type(request.content_type)
document = request.body.read
request.body.rewind
raise InvalidDocument.new(document) unless valid_schema(document)
end
def valid_schema(document)
return true unless document.present?
JSON::Validator.validate(Rails.root.join("spec/support/schema.json").to_s, document)
end
def match_content_type(content_type)
/application\/vnd\.api\+json/.match?(content_type)
end
end
Linter wraps Rails app, takes env
hash as input to the call
method and then performs checks. At first glance it verifies Content-Type
header whether one matches application.vnd.api+json
. Then it verifies request body whether it’s compliant with JSON Schema, same goes for the response. If any deviation occurs, an exception with RSpec look-a-like error is raised. If everything goes fine, the response is returned so we can check other expectations in our spec example.
To make linter working, app
method has to be overridden as I mentioned before:
def app
JsonApiLint.new(super)
end
Our complete spec now looks like:
RSpec.describe "Customers endpoint", type: :request do
specify "registering customer" do
json_data = JSON.dump({
data: {
id: "de47043c-bd1a-4592-9601-a68ad0d0ea89",
type: "customers",
attributes: {
email: "joe@example.com",
password: "Foo123Bar",
toc_agreement: true,
marketing_agreement: true,
newsletter_agreement: true,
}
}
})
post "/users", params: json_data, headers: { "CONTENT_TYPE" => "application/vnd.api+json" }
expect(response).to have_http_status(201)
expect(parsed_body["data"]["id"]).to eq("de47043c-bd1a-4592-9601-a68ad0d0ea89")
expect(parsed_body["data"]["type"]).to eq("customers")
end
def app
JsonApiLint.new(super)
end
def parsed_body
JSON.parse(response.body)
end
end
For me this test is now more compact & business value oriented.
Would you like to continue learning more?
If you enjoyed that story, subscribe to our newsletter. We share our every day struggles and solutions for building maintainable Rails apps which don’t surprise you.
You might enjoy reading:
- How and why should you use JSON API in your Rails API?
- API of the future
- Cover all test cases with #permutation
If you want to learn how to support JSON API standard in your Rails application, try our Frontend Friendly Rails Book.
Would you like me and my coworkers from Arkency to join your project? Check out our offer