Software Developer

Request Spec Realistic Error Response

Happy Request Specs πŸ”—

Let’s build some happy little trees. – Bob Ross

When using RSpec, you can create a request spec to “drive behavior through the full stack”.

I use request specs as my highest-level acceptance test for API endpoints. We’re building a system that allows users to place predefined elements on a canvas of sorts. Let’s build a test for placing a little tree on the canvas.

it "creates a happy little tree" do
  post little_trees_path,
    params: { tree: { disposition: "happy", x: 200, y: 100 } }

  expect(response).to have_http_status(:created)
end

We will also verify that we can find the tree.

it "shows an existing tree" do
  tree = FactoryBot.create(:tree, :little)

  get little_trees_path(tree)

  expect(response).to have_http_status(:ok)
end

Testing Accidents πŸ”—

We don’t make mistakes, just happy little accidents. – Bob Ross

We also want to ensure the system responds as expected when users make happy accidents. Asking this endpoint for a tree that’s not little is not found.

it "does not find a large tree" do
  tree = FactoryBot.create(:tree, :large)
  get little_trees_path(tree)

  expect(response).to have_http_status(:not_found)
end

This test does not pass! Not because we’re TDD-ing this and the implementation doesn’t exist. The endpoint does respond as expected if you hit it from curl or another way.

Our test raises an ActiveRecord::RecordNotFound exception. We know that Rails has special handling to return a 404 status code in this case. However, the request spec still raises the exception.

Prior Art πŸ”—

Anytime you learn, you gain. – Bob Ross

I didn’t figure out how to resolve this on my own. I only came across a solution by searching for others’ approaches. I found this issue in rspec-rails’s issue tracker. In there Michael Gee shares a workaround. The earliest reference to this approach that I can find is from Eliot Sykes in 2017. If you’re aware of an earlier reference to this, let me know!

Painting A Realistic Picture πŸ”—

All you need to paint is a few tools, a little instruction, and a vision in your mind. – Bob Ross

To get the production behavior in our test, we need to turn off Rails’ fancy exception page. Michael and Eliot share this method to do so:

module ErrorResponses
  def respond_without_detailed_exceptions
    env_config = Rails.application.env_config
    original_show_exceptions = env_config["action_dispatch.show_exceptions"]
    original_show_detailed_exceptions = env_config["action_dispatch.show_detailed_exceptions"]
    env_config["action_dispatch.show_exceptions"] = :all
    env_config["action_dispatch.show_detailed_exceptions"] = false
    yield
  ensure
    env_config["action_dispatch.show_exceptions"] = original_show_exceptions
    env_config["action_dispatch.show_detailed_exceptions"] = original_show_detailed_exceptions
  end
end

We pass a block to this method in our test. We store the initial values of the environment configuration, then set show_exceptions and show_details_exceptions to match production. After our block executes, we return them to their original value.

Note that prior to Rails 7.1, you’ll want to set the value of show_exceptions to true. From 7.1 on, the configuration accepts a set of symbols and using true or false is deprecated.

Let’s update our test to use this method.

it "does not find a large tree" do
  respond_without_detailed_exceptions do
    tree = FactoryBot.create(:tree, :large)
    get little_trees_path(tree)

    expect(response).to have_http_status(:not_found)
  end
end

Now our test passes! We’re returning a 404 and not raising an exception.

Using Your Tools πŸ”—

You can do anything here β€” the only prerequisite is that it makes you happy. – Bob Ross

If we’re using RSpec, we have another option, which again comes from Eliot. We can use RSpec’s around hook to pass the entire test as the block to respond_without_detailed_exceptions by applying metadata.

module ErrorResponses
  RSpec.configure do |config|
    config.include self, type: :request

    config.around(realistic_error_responses: true) do |example|
      respond_without_detailed_exceptions(&example)
    end
  end
end

That allows us to rewrite our test as follows:

it "does not find a large tree", :realistic_error_responses do
  tree = FactoryBot.create(:tree, :large)
  get little_trees_path(tree)

  expect(response).to have_http_status(:not_found)
end

Artistic Inspiration πŸ”—

Don’t forget to tell these special people in your life just how special they are to you. – Bob Ross

I cannot stress enough that this post wouldn’t exist without the contributions of Michael Gee and Eliot Sykes. I’m thankful to them for sharing this solution. I’ve used this approach in various codebases in the last few years. I’ve shared this approach with many people (and now with you). This post serves to draw attention to their work.