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
|
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, |
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 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 |
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 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.