In testing our Clojure microservices, we try to test the app completely from its external interface. For most of our services, this testing pattern requires standing up and destroying the service in each test and hitting them with HTTP requests or messages through a queue. Since we test the apps from the outside, we’re able to take advantage of the primary benefits of high-level tests: full refactorability without test modifications (including the persistence layer, choice in databases, HTTP libraries, etc.) and high confidence that all the pieces of the app are properly connected. We’ve also been able to avoid the typical pain points felt from these kinds of test (hard to pinpoint failures and slow test runtimes), mainly by keeping the services small and Clojure’s speediness.
Introducing the Standalone Test Server
External HTTP requests are quite a common side effect of applications. With higher level tests, there are a couple of common strategies used:
- Mock out the HTTP library being used at a low level (typically with some sort of re-binding)
- Separate out the code that makes requests and swap it for something else during tests
Both of these solutions limit the amount of refactoring that can be done without having to modify the tests.
The first solution may hide failures in using the HTTP library correctly. In our case, we use clj-http and so the library to mock these interactions would be clj-http-fake. At time of writing, clj-http-fake only mocks requests on the current thread and since many of our apps are backed by thread pools, we are unable to successfully use it.
The second solution (essentially providing an HTTP adapter), does not test the integration through this component.
Instead, to test our external HTTP requests, we stand up an in-process server that simply records the requests the it receives. Thanks to ring, the requests are just maps that we can make assertions against. We call this tiny library we’ve open sourced, the standalone-test-server. It’s similar in spirit to the Java moco or some libraries in other languages called “fake server”.
As with all software, there’s certain tradeoffs to be made and the standalone test server makes it difficult to test the following:
- Requests that timeout (although technically feasible, the test suite becomes much longer than reasonable)
- Testing that a request isn’t made (would again require waiting the entire timeout to ensure that the request is not on its way)
For these situations, unit tests still work great.
Here’s a small usage example of this server from the readme:
(let [[retrieve-requests handler] (recording-endpoint)] (with-standalone-server [s (standalone-server handler)] (http/get "http://localhost:4334/endpoint") ;; status 200, empty body (is (= 1 (count (retrieve-requests))))))
Let’s dig into that a little bit! The full API is documented.
From the top, the
recording-endpoint function creates both a function to retrieve the recorded requests and a Ring handler that is ready to record. We can then start up an in-process server by calling
standalone-server. By default, this in-process server runs on port 4334 and will always return status 200 responses with an empty body.
Then, we call
retrieve-requests to get a sequence of requests that have been recorded. In our test here, we just check that 1 request has been made.
retrieve-requests blocks until whichever comes first: it receives the number of requests it is requesting or until a timeout has been reached (so that tests don’t wait around forever if the requests aren’t coming). By default, this endpoint waits for 1 request with a 1 second timeout. Since this function returns as soon as it can, the timeout can be made quite long to avoid test flakiness while also keeping tests fast when everything is working.
The code under test just needs to take a url for the base URL to hit instead of determining one on its own. Oftentimes URLs for external services change based on sandbox/production already, so it often really only requires adding another url.
We use this library in many of projects and have found it to be an effective way to make maintainable high level tests. Give it a shot! And of course let us know what you think on GitHub.