On the testability of Ring Middleware in Clojure
At Helpshift, we have come to write two kinds of tests for our systems:
- Unit tests: Tests that do not need to query any external system and can run blazingly fast, and run on a per-commit basis.
- Integration tests: Tests that query databases and other systems and take longer than unit tests but are run on a nightly basis.
The line between these two kinds of tests is one that is blurry and often open to interpretation. One such area of debate is how integration tests see the system they are testing: is it a blackbox, or is it testing the handler functions for service endpoints or is it simply testing functions but unlike unit tests, it is not mocking any database calls. While that debate is for another time, in this post we consider one definition of integration tests that enables us to test middleware in Clojure’s Ring framework, while not having to treat the system as a complete blackbox.
The Ring framework’s composability is among the many superpowers that Clojure web developers can harness. To give a very brief overview, taken from https://github.com/ring-clojure/ring/wiki/Concepts :
- Requests are Clojure maps constructed from the HTTP requests.
- Handlers are functions that compute the response for a request made on an endpoint.
- Middleware are functions that add extra functionality to handlers. Middleware sit in between the handler for the service endpoint and the HTTP server. The utility of middleware is to provide common functionality which is shared across endpoints, for example authentication or identity verification — or in other words, non-endpoint specific logic which applies to most endpoints.
- Responses are Clojure maps which Ring converts to HTTP responses.
As an example, let us look at the middleware defined in the Github repository of Ring:
Here handlers
is a function that defines a chain of business logic and transformations for each HTTP route. The wrap-*
functions are middleware, which compose over the handlers
. For example, wrap-content-type
can be written as:
This is taking a function and a string (the content-type of the response), and returning another function. This new function now becomes the input to the next middleware wrap-keyword-params
. This is how middleware compose and are of great power.
Now let us look at a sample handler
. We can use compojure
here, which helps write the routing logic concisely. Note that these are all Clojure functions and one could very well write simple functions that look at the request parameters and call appropriate functions. However, using compojure
, it is easier to read and it provides some additional functionalities for routing. Our handlers
could be defined as follows:
Here, the routing happens via compojure
when a request matches the HTTP method and the path. When that happens, if the route is a value, it is returned directly, otherwise, if it is a function, it is called.
In short, a very common server stack, and the one we use at Helpshift, can be depicted as:
While this is great, a common problem, as we started off the discussion with, is to decide at which layer our tests should concentrate. Tests could written in one of three ways.
- Test only the business handler: With this, we assume that the maximum complexity is in the business logic. Specifically in the context of Helpshift, the complexity in business handler functions is often more than the routing of HTTP requests. For example, a handler function to detect cycles in a chatbot configuration entails the most complicated aspect of the entire request, far outweighing the routing of the request itself. In this testing approach, we only write tests for the handler functions and not the middleware, thereby focusing on the complicated business modules rather than the routing of the requests.
- Test the whole server as a blackbox making HTTP routes from an external service: In this testing framework, where we make requests to the server from outside the server process. Since we make requests here to the HTTP server through network calls, the entire stack is tested, including the middleware and routing, just like it would happen if a browser made the HTTP request.
- A hybrid solution with the best of both: The downside of testing the server as a blackbox is that the testing layer does not have any way to verify if the internals are correctly working. For example, if the server internally maintains some sort of state invisible to the client, that just cannot be tested via the blackbox means. In this approach, we start an embedded HTTP server in the test setup and make requests to it like a blackbox would, but this time, since we are testing this as part of the application, we can verify all internal state while not losing any functionality of blackbox testing.
The most dynamic part of any product is the business logic. As mentioned before, most of the business logic can be contained in handler functions which are invoked from the routing layer. Hence, most of our tests focused on these handlers. But that changed when we had to build a feature to manage user sessions which expire after a certain time period. A watered down version of such a session-management strategy is as follows:
- Users log in with credentials
- While authenticated, they can access all routes
- Auto log out after 10 secs since login
- Track number of times a route is accessed and a list of users who have logged in
These session rules should apply to all authenticated routes of the server, and hence it makes sense to implement it as a middleware: a “session timeout” middleware. Along with this, there is also a “session management” middleware which stores all user sessions in a key-value store.
In a more complicated production server, there are so many moving parts, it is imperative to test the outcome of composed middleware and handlers. Moreover, since middleware compose over one another, it is possible that middleware undo each other’s changes, that is, the order in which middleware functions are applied is also important.
Our server can be implemented as shown below:
Let us now test for each of the four features we set out to implement.
Step 0: Setting up the test server
We can use the same server from the source, which now runs inside the JVM that’s running the test. This allows for the flexibility we are looking for: from complete blackbox testing to a test suite with access to the internal state of the server.
Step 1: Testing the login flow
Now that the server is set up, we can make HTTP requests to our server and all the middleware, handlers, marshalling, unmarshalling of data will happen in all its glory, as it would when accessed from a browser.
Let’s look at the flow when the user is not logged in, or enters invalid credentials:
Here we mainly test that when a user is not logged in, accessing authenticated pages redirects the user to the login page. We use thetrace-redirects
metadata from clj-http
to check for redirects: when not authenticated, the server should redirect to the login page.
Let’s look at a valid login flow:
Here we emulate the browser by using a cookie-store, which stores the cookies the server sets and sends it along in every request, just like the browser would. We can get access to the cookies through the get-cookies
function provided by clj-http
. With this and trace-redirects
we can ensure that if a user is logged in, they will have access to the authenticated routes.
Step 2: Testing the session timeout logic
Middleware in Ring are simply higher order functions, and just like any function in Clojure, we can write unit tests for that function. This can be applied to middleware too. In the following code snippet, we test the session timeout middleware in isolation by passing to it a dummy handler and testing that when the incoming request has an active session, the middleware simply passes the request to the handler, and if the session has expired, the middleware stops the request and returns to the client with a proper error code. Note that no HTTP request is made in this test, as it is purely a unit test.
In the above snippet, we tested that the session middleware itself but there is a lot more that happens to a request when it an actual HTTP request is made. For example, there are other middleware that are applied to the request, there is the conversion of HTTP cookies to the session map the wrap-session-timeout
middleware relies on, and then there is the actual call to the correct handler depending on the request. In the following snippet, we test the end-to-end flow of a request, in the context of session timeouts:
We had implemented the session timeout logic as a middleware but since we are making HTTP requests, both middleware and handlers are invoked. The expectation in our server is that after 10 seconds, the user should be auto-logged out, i.e., they should be asked to enter login credentials again. Now, since we are running the server inside the same JVM that is running the tests, we can test this without actually waiting 10 seconds by redefining the current-time, which we do here by mocking the clj-time.core/now
function, which is also used by the server to determine if a session has timed out. Once that is done, accessing an authenticated route should redirect the user to the login page, which we test through trace-redirects
.
Step 3: Testing server metrics
Our server captures two kinds of metrics: number of requests per route, and the usernames of people who have logged in. The former is accessible to the end user via the /stats
route and the latter is not exposed to the end user. This is a simple example where a blackbox test would just not have access to this metric. However, for our case, we can just refer to the in-memory atom which contains our metrics.
Here we simply access the atom which stores all the metrics to ensure the correctness of the collected metrics. A subtle trick we used here was to have two users log in at the same time. We can do this because both these users are considered different sessions by the server, and the cookies which mark their sessions are different — this is done by using different cookie stores for each user. The server, being stateless, relies only on the cookie it receives.
Lessons
This was a toy example where we created a rather simple server and in real production systems, it would be much more complicated but the principles are transferable. In fact, we use this testing methodology to test actual sessions on some of our servers at Helpshift. To summarize our experience:
- Testing middleware is hard, it only gets more complicated when multiple middleware compose over handlers
- Testing business logic via directly testing the handler function should still be the go-to for most features as making HTTP requests to test every functionality is an overkill and will slow down our test suite
- For testing some features like session management, having an embedded server allows for accessing the server’s internal state, and redefining the behaviour of the server, while not losing any functionality of a traditional blackbox test suite
clj-http
has very powerful tools we can use in our test suites, of which we only discussed a few: using a cookie-store can emulate the browser’s cookie management for multiple requests, checking metadata of response liketrace-redirects