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
endCreating 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
...
endRack::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
endThe 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