Go to main contentGo to footer
Ruby
|
03 October 18

Testing Rack applications

Testing HTTP requests inside a Rails application is easy. But how can we do it when we're writing a pure Ruby gem with no external support?

Anarchy outside Rails

I've spent the last 2 months working on AgID's [SPID system] (https://www.agid.gov.it/it/piattaforme/spid) to make it possible to integrate it into any Rails application.

But since we wanted to make SPID available to all Ruby developers, not just Rails ones, we decided to go down a level and design a fully framework-independent version, then build the Sinatra or Rails adapters on top of it later. So the choice fell on good old Rack.

As long as it was a matter of unit testing specific features of the application, testing was easy and convenient. But when I started testing requests on Rack, I struggled to find an approach that made specs easy to write and clear to read.

While with rspec-rails setting up request specs is almost standard, in the Rack world I found various approaches, but in my opinion they were all incomprehensible. The ones closest to what I had in mind all depended on Sinatra.

In the end, by piecing together bits of information, I managed to crack the problem. Let's walk through it.

A sample middleware

Let's say we need to test a Rack module that, when it recognizes a given request path, responds with some of the env variables.

class MyExampleRackMiddleware
  attr_reader :app

  def initialize(app)
    @app = app
  end

  def call(env)
    request = Rack::Request.new(env)

    if request.path == "/server-info"
      return [
        200,
        {},
        [ request.fetch_header("MY_HEADER") ]
      ]
    end

    app.call(env)
  end
end

Creating the test app

The first step is to define, inside our spec, a sort of mini application to use as the subject of our tests. To do this, we can use Rack::Builder.

RSpec.describe MyExampleRackMiddleware do
  let(:app) do
    Rack::Builder.new do
      use MyExampleRackMiddleware
      run ->(_env) { [200, { "Content-Type" => "text/plain" }, ["OK"]] }
    end
  end

  ...
end

Rack::Builder is simply the mechanism underlying the Rails and Sinatra stacks, or any other Rack-compatible application. So we can treat the app object as a full-fledged Rack application.

HTTP requests

Now we need to be able to handle individual HTTP requests. We can do this with the Rack::MockRequest class.

let(:request) { Rack::MockRequest.new(app) }

Rack::MockRequest creates a small wrapper around the app application we want to test. In particular, it gives us handy methods for making HTTP calls and returns a Rack::MockResponse object that lets us interact with the response easily.

Specifically, a Rack::MockRequest object provides a method for every possible HTTP method: .get, .post, .delete with a simplified signature

request.get("/a-path", params: { id: 1 }, "key" => value, ...)

Everything that isn't params is automatically added to the request env, while params goes either into the URL or into the body (when using .post, .put)

In the case above, the actual request path will be /a-path?id=1 and we could use env["key"] to access value.

We recommend always converting the env object into a Rack::Request

request = Rack::Request.new(env)

because it spares whoever writes the Rack middleware from having to know the environment variables in detail, and it gives simplified access to .params or .session without having to worry about how.

For our middleware, a possible example could be this

describe "GET /server-info" do
  let(:response) do
    request.get("/server-info", params: {})
  end
end

The final test

With all the pieces in place, we can write the test like this:

RSpec.describe MyExampleRackMiddleware do
  let(:app) do
    Rack::Builder.new do
      use MyExampleRackMiddleware
      run ->(_env) { [200, { "Content-Type" => "text/plain", ["OK"] }] }
    end
  end

  let(:request) { Rack::MockRequest.new(app) }

  describe "GET /server-info" do
    let(:response) do
      request.get("/server-info", "MY_HEADER" => "my-header-value")
    end

    it "returns 'MY_HEADER' content in response body" do
      expect(response.body).to eq "my-header-value"
    end
  end

  describe "GET /another-path" do
    let(:response) do
      request.get("/another-path", "MY_HEADER" => "my-header-value")
    end

    it "pass the request to the next middleware" do
      expect(response.body).to eq "OK"
    end
  end
end
footer