On the testability of Ring Middleware in Clojure

Mourjo Sen
Dec 6, 2019 · 8 min read

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:

Image for post
Image for post

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
Image for post
Image for post
Our simple web application in action

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:

Code for the blog, abbreviated, the full version available at https://github.com/mourjo/lganguly/blob/master/src/lganguly/helloserver.clj

Let us now test for each of the four features we set out to implement.

Step 0: Setting up the test server

Step 1: Testing the login flow

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

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

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

  • 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 like trace-redirects

helpshift-engineering

Engineering blog for helpshift

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store