Unit testing

We believe in testing, and Pedestal’s design encourages you to write useful, meaningful, simple tests.

Your application will consist of some amount of the following categories of code (going from most application-specific to least):

  • Business logic

  • Handler functions (calling business logic)

  • Application-specific interceptors

  • Pedestal-provided interceptors

This guide focuses on unit testing of your application-specific interceptors and handlers. However, as they say, the whole is more than the sum of the parts …​ that is, your handlers will often rely on the incoming request being setup in specific ways due to the interceptors that execute before it; such as the way the body-params interceptor parses the :body InputStream as JSON or EDN data, and stores that into the request map as :json-params or :edn-params.

At least some of your testing should be "full stack", to exercise these parts: the routing logic, and transformations of the request and response map. Pedestal makes it fast and easy to test the whole stack in this way.

This document describes unit testing when using io.pedestal.connector APIs to create your service, the 0.7 version of this documentation discusses the equivalent work when using the io.pedestal.http APIs.

We’ll start with isolated testing of interceptors, before moving on to grander things.

Testing interceptors in isolation

Interceptors can be tested in isolation by either directly invoking the :enter, :leave or :error functions with a mock context map in your test or by using the interceptor chain execute function. Both approaches are demonstrated below.

Given the following interceptor:

(def widget-finder
     (interceptor/interceptor
      {:enter (fn [context]
                (assoc context :widget {:id 1 :title "foobar"} ))}))

This is the barest form of an interceptor; it ignores the incoming context and blindly adds a new key to it.

We can test it either directly:

(let [enter-fn (:enter widget-finder)]
  (is (= {:id 1 :title "foobar"}
         (:widget (enter-fn {})))))

... but because its so simple, we can pass an empty context to the interceptor and still assert that is makes the desires change to the context.

Testing multiple interceptors

To test one or more interceptors' behavior as part of the interceptor chain, it is nearly as easy; we can levarage execute to execute a chain of interceptors, and get the final context map.

(is (= {:id 1 :title "foobar"} (:widget (chain/execute {} [widget-finder]))))

The value, widget-finder in this example, must be an interceptor, not a map that can be converted into an interceptor. The interceptor function ensures this.

We can extend this approach to test coordination across multiple interceptors as follows:

(def widget-renderer
  (interceptor/interceptor
   {:leave (fn [context]
             (if-let [widget (:widget context)]
               (assoc context :response {:status 200
                                         :body   (format "Widget ID %d, Title '%s'"
                                                     (:id widget)
                                                     (:title widget))})
               (assoc context :response {:status 404
                                         :body "Not Found"})))}))

(is (= "Widget ID 1, Title 'foobar'"
       (get-in (chain/execute {} [widget-renderer widget-finder])
               [:response :body])))

However, once you find yourself testing a few interceptors together this way, it may make more sense to test the entire stack, starting from the incoming request map.

Testing your service with response-for

The value in exercising the end-to-end operation of your service endpoints is that it provides quick feedback that you’ve wired things up correctly. Pedestal provides the test helper function response-for, which makes it possible to fully test a route without starting up an HTTP service.

This style of testing ensures that routing is correct, and it fully exercises routing, interceptors, and any business logic.

The response-for function works with an initialized, but not started, connector.

response-for will build a request map, and have the connector execute it (as if it was a request that came in from an HTTP request). response-for returns a slightly modified version of the response map.

(let [response (response-for connector :get "/api/hello")]
   ...)

Another example of response-for use is in the Pedestal with Component guide.

response-for arguments

response-for has three required arguments, followed by optional named arguments.

The first three arguments are:

  • The Pedestal connector

  • The HTTP request method, a keyword (:get, :post, :delete, …​)

  • The url as a complete URL, or relative to the root of the service

The provided URL is parsed; if it is a complete URL (starting with "http://" or "https://") then the following keys will be set in the request map:

  • :scheme (defaults to :http)

  • :server-name (defaults to "localhost")

  • :server-port (defaults to -1)

Remember that request routing may take into account the scheme, server name, and port, as well as the request method and path.

In addition, the :uri and :query-string keys will be set from the URI:

  • The :uri starts with a leading slash and extends to the end of the URL, or the start of the query string.

  • The :query-string is anything following a ?.

Following the three required arguments are further optional arguments, as key/value pairs.

:headers

A map of keys and values for the headers to send in the request.

Keys and values are normally strings, but you may also use keywords or symbols for keys and values; they will be converted to strings. [1] Header strings are always converted to lower case.

:body

Defines the :body of the request. The value provided may be a String, an InputStream, or a File. This will be converted to an InputStream in the request :body.

Advanced users can extend the RequestBodyCoercion protocol onto new types to support additional :body types.

:as

The response body will normally be nil or an InputStream; the :as parameter enables a conversion to something easier for tests.

The default conversion is :string (treat the response as a UTF-8 encoded byte stream), but the values :byte-buffer and :stream are also allowed.

response map

To assist with testing, the headers in the response map are modified; normally they consist of string values and string keys; response-for converts the keys to lower case keywords. For example, the "Content-Type" header will be converted to :content-type. The values are unchanged, but keywords are easier to oeprate on in test assertions.

Testing GET requests

The following example illustrates a simple execution of response-for within a test:

(is (= "Hello!" (:body (response-for connector :get "/hello"))))

A GET request has no body to specify, so the :body argument is simply omitted.

A test could also make assertions about the response headers:

(let [response (response-for connector :get "/hello")]
  (is (= "text/plain"
      (get-in response [:headers :content-type]))))

Using the extremely useful matcher-combinators library, we can combine these tests into one (and verify the response status for good measure):

(is (match? {:status 200
             :headers {:content-type "text/plain"}
             :body "Hello!"}
            (response-for connector :get "/hello")))

Testing POST

POSTing to a service endpoint can be tested by using the :post verb and specifying a request :body. The route under test typically includes the body-params interceptor to support request payload parsing.

body-params uses the Content-Type header to identify how to parse the body; different content types result in different keys being added to the request. In any case, it is essential that the content type to be specified as part of the response-for call.

(is (match? {:status 200}
            (response-for connector :post "/foo"
                          :headers {:content-type "application/json"}
                          :body "{\"foo\":\"bar\"}")))

If testing an HTTP form submission, the approach is similar except for how the content is encoded and described.

(is (match? {:status 200}
            (response-for connector :post "/login"
                          :headers {:content-type "application/x-www-form-urlencoded"}
                          :body "username=test@test.com&password=my-pwd")))

Testing Async Processing

Nothing special needs to be done when testing routes which include async processing, response-for will only return once a response has been generated, even if request processing goes asynchronous.

Testing WebSockets and SSE

Truly asynchronous operations, such as WebSockets and Server-Sent Events can’t be tested using response-for as it blocks until a single response is produced.

To test these kinds of asynchronous operations, it is necessary to fully start the connector and have a client send proper requests to the service - this is integration testing, beyond the scope of this document.


1. Qualified keywords or symbols will lose the namespace part.