Unit testing
It is a good practice to separate core business logic from handler, and more generally, interceptor code. This separation of concerns has many benefits, including facilitating testing at the interceptor, interceptor chain and end-to-end response generation levels.
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 [ctx]
(assoc ctx :widget {:id 1 :title "foobar"} ))}))
We can test it either directly:
(let [test-fn (:enter widget-finder)]
(is (= {:id 1 :title "foobar"} (:widget (test-fn {})))))
Or using the interceptor chain execute
function:
(is (= {:id 1 :title "foobar"} (:widget (chain/execute {} [widget-finder]))))
We can extend this approach to test coordination across multiple interceptors as follows:
(def widget-renderer
(interceptor/interceptor
{:leave (fn [ctx]
(if-let [widget (:widget ctx)]
(assoc ctx :response {:status 200
:body (format "Widget ID %d, Title '%s'"
(:id widget)
(:title widget))})
(assoc ctx :response {:status 404 :body "Not Found"})))}))
(is (= "Widget ID 1, Title 'foobar'"
(get-in (chain/execute {} [widget-renderer widget-finder])
[:response :body])))
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 is specific to servlet-based
services and simulates the receipt of a servlet request and creation
of a servlet response - but does so without the overhead of an actual HTTP connection, which
ensures your tests are fast.
response-for
takes as arguments a service function, verb and url as
required parameters; headers and a request body are
optional. Let us examine those parameters in more detail.
The service function refers to the :io.pedestal.http/service-fn key value in the service map during service initialization.
The verb is an HTTP method represented as a keyword (i.e., :get, :post, :delete, etc…).
The url is a relative url represented as a string.
The headers and request body inputs are optional and specified with the :headers and :body keys, respectively.
Usage
Before using response-for
in tests, a test service must be
created. This is done by calling
create-servlet
with the service map as a parameter. The resulting service-fn can be
bound to a var and used in subsequent tests.
(def service (:io.pedestal.http/service-fn (io.pedestal.http/create-servlet service-map)))
Testing GET
The following example illustrates a simple execution of response-for
within a test:
(is (= "Hello!" (:body (response-for service :get "/hello"))))
The response returned by response-for
contains :status, :body
and :header keys. The value of the :headers key is a map with
stringified keys. Testing header values would look something like
this:
(is (= "text/plain"
(get-in (response-for service :get "/hello") [:headers "Content-Type"])))
Testing POST
POST’ing 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. Therefore, you will
need to set the Content-Type
header of the test request to the
appropriate value based on the payload format of the request body.
(is (= 200 (:status (response-for service
:post "/foo"
:headers {"Content-Type" "application/json"}
:body "{\"foo\":\"bar\"}"))))
(is (= 200 (:status (response-for service
:post "/foo-login"
:headers {"Content-Type" "application/x-www-form-urlencoded"}
:body "username=test@test.com&password=my-pwd"))))
Notice how Content-Type
is a string.
Constructing route URLs
Pedestal provides a utility,
url-for-routes
.
This is passed the expanded routes for your application, and returns a function that will generate
a URL for a specific route; primarily by specifying the route name, but also other factors.
It is recommended to create a url-for
test helper based on url-for-routes
, and
use that helper to create the relative urls for your tests. This allows you to
refer to routes by name as opposed to hard-coded paths. Route names
are typically more stable. Aside from that, it makes it easy to test
routes which use query or path parameters.
Here’s an example:
(require '[io.pedestal.http.route :as http.routes])
(def routes #{["/user/:id/items" :post `user-items]} ; (1)
(def url-for (http.routes/url-for-routes (http.routes/expand-routes routes))))
(is (= 200 (:status (response-for service
:get (url-for ::user-items ;(2)
:path-params {:id 1}
:query-params {:sort "ASC"})))))
1 | The namespace qualified symbol for user-items will be the default route name (as a keyword) |
2 | The route name is used to get the URL back |