Heading image for post: ActiveRecord to JSON API Part 7: Testing

ActiveRecord to JSON API

ActiveRecord to JSON API Part 7: Testing

Profile picture of Mary Lee

To wrap up this series, let’s talk about the helpers we added to make writing tests with the JsonApiClient resources less painful.

Background

We were building an internal JSON API using the following libraries:

For more information on our goals and set up for the internal API, see part 1 of this series.

Creating Factories

FactoryBot makes it incredibly easy to build factories for non-database backed resources using the skip_create option. We made factories for all of our resources, to make it easier to stub and work with the resources in tests.

factory :foo do
  skip_create
  sequence(:id)
  sequence(:title) { |n| "title#{n}" }
end

Setting Up Test Helpers

Writing WebMock stubs for JsonApiClient required a lot of knowledge of JSON API specifications, and was tedious to have to write over and over again, so we decided to add some test helpers to streamline the process.

We knew we wanted to be able to support CRUD stubs for all of our resources being converted, and we wanted to make it as intuitive as possible for future developers on the project, so we decided to implement the helpers in a way that meant every model being converted from ActiveRecord to the API had a method for stubs (so our Foo model would have stub_get_foos, stub_create_foo, stub_delete_foo, etc).

We created a new file in our RSpec support directory, and then created a constant that would be a list of all of the ActiveRecord models being converted to JsonApiClient resources.

module API
  module Mocks
    API_RESOURCES = %i[
      foo
      bar
      # … 
    ]
  end
end

Generating Routes

Our first step was figuring out how to build the routes for all of our stubs. We knew that we wanted to be able to pass an API resource type, and generate our paths from that. We started by defining our base path. Our method accepted a resource type, and then pluralized the resource type, appending it to the base path for our API.

def resource_base_path(resource_type)
  "#{ ENV['LOCAL_API_URL'] }/#{ resource_type.to_s.pluralize }"
end

We then moved on to generating the index path for the resources. We knew we needed to be able to support query string params on the index, for the sake of sorts, eager loading, and pagination. We wanted to be able to specify our exact params, but also allow for a general match on any params that might come through. If exact params were given, they were converted to query strings, otherwise we used a general regex to match on any characters that might come through.

def resource_index_path(resource_type, params: nil)
  params_matcher = if params.present?
    # Generate query string if params are passed
    params.to_query.gsub("+", "%20")
  else
    # Match on any characters that might come through if no params are present
    ".*"
  end


  # Generate the final regex
  /#{ resource_base_path(resource_type) }($|\?#{ params_matcher })/
end

Last, we tackled the individual resource path. We wanted to be flexible with the path, allowing the stubbed URL to be as specific or vague as needed. For us, this meant accounting for the optional presence of an ID and query string params. If an ID was passed, we would use that ID in the stubbed URL, otherwise we would use a regex that would match on the presence of any number of integers. If query string params were passed, we would append those to the URL we were building.

def resource_show_path(resource_type, id: nil, params: nil)
  # Match on exact ID or use regex to match on any number
  id_matcher = id.presence || "[0-9]+"

  # Generate query string from params passed if present, otherwise, use an empty string
  query_string = params.present? ? URI.unescape(params.to_query) : ""

  # Generate the final regex
  %r{#{resource_base_path(resource_type)}/(#{id_matcher})($|\?#{query_string})}
end

With our path helpers defined, we could now move on to defining our stub helpers.

Stubbing the Index

The first stub we tackled was for API indexes. The index stub wound up being our most complicated stub because of work earlier in the project to make our index requests POSTs instead of GETs if filtering params were present. That customization meant that our stub had to support both the GET and POST requests. It also needed to be able to support pagination, and allow us to specify the response body or status if we wanted to.

For integrating pagination, we knew that JSON spec required a response with a data key for returned resources, and then a links key for pagination, if pagination was present. Based on our test requirements, we knew we only needed to support a response that included pagination links, but the links didn’t need to work beyond helping us stub resource counts in our tests (see how we used pagination for getting counts in part 6 of this series). For this reason, we didn’t need a URL that would match up to the index URL we were working with; the only important thing for us was the link params.

We created a reusable method called build_response_body that would accept a response body to set to the data key in the JSON response, as well as options that would allow us to specify whether or not we wanted to include pagination links.

def build_response_body(response_body, options = {})
  # Set the data key with our response body
  { data: response_body }.tap do |response|

    # Check if we wanted to include pagination
    if options[:include_pagination_links]

      # Set the links key with our first, next, and last pages for pagination
      response[:links] = {
        first: "http://localhost?page[number]=1&page[size]=1",
        next: "http://localhost?page[number]=2&page[size]=1",
        last: "http://localhost?page[number]=3&page[size]=1"
      }
    end
  end
end

To handle the POST request when filter params were present, we added a stub_filter_resource method. This method would accept a resource type, using it to build the index path stub, as well as initializing objects to return for the response if the response was filtering resources by IDs. The method would also accept options for specifying a response status or response body. If no IDs were passed in the filters, and no response body was specified, we defaulted to returning an empty array.

def stub_filter_resource(resource_type, options = {})
  # Stub the POST request with the resource URL
  WebMock.stub_request(:post, "#{ resource_base_path(resource_type) }/filter").

    # Send the specified filtering params as the request body
    with(body: options[:params]&.to_json || /.*/).
    to_return do |request|

      # If no response body was specified, try to build a response using request params,
      #  checking to see if IDs were used to filter. If IDs were present, build FactoryBot
      #  objects with the IDs, otherwise return an empty array.
      unless (response_body = options[:response_body])
        ids = Array(JSON.parse(request.body).dig("filter", "id")) || []
        response_body = ids.map { |id| FactoryBot.build(resource_type, id: id) }
      end

      {
        status: options.fetch(:response_status, 200),
        body: build_response_body(response_body, options).to_json,
        headers: { "Content-Type" => "application/vnd.api+json" }
      }
    end
end

Finally, to handle the GET request, we added a stub_get_resources method, again accepting a resource type for building the request URL. We chose to have our stub_get_resources always set the stub for filtering, because we felt that it would be less of a burden on us and future developers trying to stub index requests in the tests.

def stub_get_resources(resource_type, options = {})
  stub_filter_resource(resource_type, options)
  path = resource_index_path(resource_type, options.slice(:params))

  response_body = options[:response_body] || []

  WebMock.stub_request(:get, path).to_return do
    {
      status: options.fetch(:response_status, 200),
      body: build_response_body(response_body, options).to_json,
      headers: { "Content-Type" => "application/vnd.api+json" }
    }
  end
end

For each resource in our API_RESOURCES constant, we then defined a helper method that would delegate to our new stubs.

API_RESOURCES.each do |resource_type|
  define_method "stub_get_#{ resource_type.to_s.pluralize }" do |options = {}|
    stub_get_resources(resource_type, options)
  end
end

Now we could use our stub in the tests!

foo = FactoryBot.create(:foo)

# Basic stub, specifying a response body
stub_get_foos(response_body: [ foo ])

# Stub including pagination and specifying a response body
stub_get_foos(response_body: [ foo ], include_pagination_links: true)

# Stub with filters, fields, pagination, and sorting
#  Ex: Foo.includes(:fizz).where(with_bars: true).select(:id, :title).order(:title).page(1).per(1)
stub_get_foos(
  params: {
    filter: { with_bars: true },
    fields: { foos: "id,title" },
    sort: :title,
    page: { number: 1, size: 1 },
    include: :fizz
  },
  response_body: [ foo ]
)

A param parser would probably be a useful addition to our stub helper, particularly for stubbing select calls, because the fields setup is confusing and unclear when writing the stub. We could probably refactor to allow users to pass { select: “id, title” } and then parse it to send the proper syntax.

Stubbing Show

Creating the stub for fetching a single resource was pretty straightforward, especially after dealing with the index. We needed to be able to support manually passing an ID, response body, and response status, but we also needed to be able to handle the stub if nothing was passed. Our default behavior if nothing was passed was to return a newly built factory object, setting the ID on the object based on the URL that was requested. That behavior could then be changed if a response body was passed.

def stub_get_resource(resource_type, options = {})
  resource_path = resource_show_path(resource_type, options.slice(:id, :params))

  WebMock.stub_request(:get, resource_path).to_return do |request|
    # Set response body to provided response body if it's sent
    unless (response_body = options[:response_body])
      # If no response body is provided, use the request URI to get the ID and build a new object
      id = request.uri.to_s.match(resource_path).captures[0]
      response_body = FactoryBot.build(resource_type, id: id)
    end

    {
      status: options.fetch(:response_status, 200),
      body: { data: response_body }.to_json,
      headers: { "Content-Type" => "application/vnd.api+json" }
    }
  end
end

# ...

# Add the resource-level helper method for calling the stub
API_RESOURCES.each do |resource_type|
  # ...

  define_method "stub_get_#{ resource_type }" do |options = {}|
    stub_get_resource(resource_type, options)
  end
end

We could then use the stub in our tests whenever we were fetching a specific resource.

foo = FactoryBot.create(:foo)

# Basic stub, specifying request ID
stub_get_foo(id: foo.id)

# Specify ID and response body
stub_get_foo(id: foo.id, response_body: foo)

Stubbing Create

To stub create, we knew we wanted to be able to support successes and failures, and we wanted to be able to specify the failures that occurred. We also needed to know what the API was expecting from the form data. This meant we had four potential options to deal with, request body, and response body, errors, and status.

def stub_create_resource(resource_type, options = {})
  # Get the form data from the options request body
  resource_data = options.fetch(:request_body, {})

  response = if options[:response_errors]
    # Set errors if response errors were given
    { errors: options[:response_errors] }
  elsif options[:response_body]
    # Set response if response body was given
    { data: options[:response_body] }
  else
    # Create a new resource from form data if no errors or response body were given
    { data: FactoryBot.build(resource_type, resource_data) }
  end

  WebMock.stub_request(:post, resource_base_path(resource_type)).
    with(body: { data: { type: resource_type.to_s.pluralize, attributes: resource_data } }.to_json).
    to_return(
      status: options.fetch(:response_status, 201),
      body: response.to_json,
      headers: { "Content-Type" => "application/vnd.api+json" }
    )
end

# ...

# Add the resource-level helper method for calling the stub
API_RESOURCES.each do |resource_type|
  # ...

  define_method "stub_create_#{ resource_type }" do |options = {}|
    stub_create_resource(resource_type, options)
  end
end

Our biggest fight on stubbing the create method for our API resources was that the form data had to be in the exact right order when generating the mock. This meant that when we were writing our tests, we would allow the tests to fail due to missing stubs first, and then use the suggested stub from WebMock to write out the attributes in our stubs.

# Stub successful create
stub_create_foo(request_body: { title: "New Foo" })

# Stub unsuccessful create
stub_create_foo(
  request_body: { fizz: "buzz" },
  response_status: 422,
  response_errors: [
    {
      title: "can't be blank",
      source: { pointer: "/data/attributes/foo_title" }
    }
  ]
)

The response error format is based off of JSON spec. This is another case where we probably could have made some sort of parser to make it easier on the end users, so they didn’t have to remember the weird format.

Stubbing Update

Setting up the update stub was pretty much exactly the same as the create stub, just swapping out the HTTP request method, and adding specific ID support. Because of the form data requirement on update, we chose to force the ID to be present, throwing an error if the key was missing from the options.

def stub_update_resource(resource_type, options = {})
  # Get the form data from the options request body
  resource_data = options.fetch(:request_body, {})

  # Fetch the ID from options, throwing an error if it is missing
  id = options.fetch(:id)

  response = if options[:response_errors]
    # Set errors if response errors were given
    { errors: options[:response_errors] }
  elsif options[:response_body]
    # Set response if response body was given
    { data: options[:response_body] }
  else
    # Create a new resource from form data and ID if no errors or response body were given
    { data: FactoryBot.build(resource_type, resource_data.merge(id: id)) }
  end

  WebMock.stub_request(:patch, resource_show_path(resource_type, id: id)).
    with(body: { data: { id: id, type: resource_type.to_s.pluralize, attributes: resource_data } }.to_json).
    to_return(
      status: options.fetch(:response_status, 200),
      body: response.to_json,
      headers: { "Content-Type" => "application/vnd.api+json" }
    )
end

# ...

# Add the resource-level helper method for calling the stub
API_RESOURCES.each do |resource_type|
  # ...

  define_method "stub_update_#{ resource_type }" do |options = {}|
    stub_update_resource(resource_type, options)
  end
end

The stub could then be used exactly the same way as the create stub, just passing in the resource ID.

foo = FactoryBot.create(:foo)

# Stub successful update
stub_update_foo(
  id: foo.id,
  request_body: { title: "New foo title" }
)

# Stub unsuccessful update
stub_update_foo(
  id: foo.id,
  request_body: { title: nil },
  response_status: 422,
  response_errors: [
    {
      title: "can't be blank",
      source: { pointer: "/data/attributes/title" }
    }
  ]
)

Just like with the create method, the form data had to be in the exact right order when generating the update mock, meaning we continued our pattern of letting the tests fail due to missing stubs, and using the WebMock suggested stub to determine our data ordering.

Stubbing Delete

Finally, we got to the last of our CRUD methods to stub out, delete. We set up our delete stub to allow an ID to be passed, as well as a response status.

def stub_delete_resource(resource_type, options = {})
  resource_path = resource_show_path(resource_type, options.slice(:id))

  WebMock.stub_request(:delete, resource_path).
    to_return(
      status: options.fetch(:response_status, 204)
    )
end

# ...

# Add the resource-level helper method for calling the stub
API_RESOURCES.each do |resource_type|
  # ...

  define_method "stub_delete_#{ resource_type }" do |options = {}|
    stub_delete_resource(resource_type, options)
  end
end

It could then be used in our tests wherever we needed it.

foo = FactoryBot.create(:foo)
stub_delete_foo(id: foo.id)

Closing Thoughts

Writing the stub helpers for the tests was a bit time consuming, but once they were done, they were a huge boon to us as we updated tests. #worthit

Check out the rest of this series!