Kerodon provides a friendly API for interacting with web pages in tests. With Kerodon you write a test from the perspective of a user interacting with a web application from a browser. It provides helpers for visiting a page, clicking on a button or a link, interacting with a form and inspecting the page content.
Kerodon API will be enough for writing most acceptance tests that don’t require JavaScript for a typical web application. But, there are cases when you want to check if a side effect was performed, something that’s not possible from a browser’s perspective. An example of a side effect is saving a user to the database, sending an email or SMS or scheduling a background job. For these cases you will need to write a custom Kerodon matcher.
In Testing Clojure web applications with Kerodon we showed how to create a simple Compojure web application and how to write tests with Kerodon. In this tutorial we will expand the application to include a side effect and write a custom Kerodon matcher to test it.
Before we begin developing a matcher, we include the full code of the application from Testing Clojure web applications with Kerodon tutorial to get you started. If you already followed steps from the tutorial, feel free to skip to the “Writing a custom Kerodon matcher” section.
Setting up the project
Prerequisites
For developing Clojure application from the tutorial you need:
- Java JDK version 6 or later.
- Leiningen 2.
Example application
It the tutorial we will use Personalized Greeting web application.
It displays a form on the homepage:
And a friendly greeting after submitting the form:
Create a Compojure application
Leiningen provides a Compojure template that allows us to get started with Compojure quickly.
Create Compojure based Clojure project:
lein new compojure kerodon-tutorial
Navigate to the project directory:
cd kerodon-tutorial
Since Leiningen generated an example test that’s using clojure.test
and we’re concentrating on Kerodon instead, let’s remove the test:
rm test/kerodon_tutorial/core/handler_test.clj
Install Kerodon and Hiccup
For generating HTML in the project, we will use Hiccup. Hiccup provides a DSL for rendering HTML. For example, to render a simple span
you would use (html [:span {:class "foo"} "bar"])
that renders "<span class=\"foo\">bar</span>"
.
Install Kerodon and Hiccup by adding the project dependency to project.clj
.
(defproject kerodon-tutorial "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:min-lein-version "2.0.0"
:dependencies [[org.clojure/clojure "1.6.0"]
[compojure "1.3.1"]
[hiccup "1.0.5"]
[ring/ring-defaults "0.1.2"]]
:plugins [[lein-ring "0.8.13"]]
:ring {:handler kerodon-tutorial.core.handler/app}
:profiles
{:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
[kerodon "0.5.0"]
[ring-mock "0.1.5"]]}})
Create the features
directory where you will put Kerodon tests:
mkdir test/kerodon_tutorial/features
Implement the greeting
Let’s write a test that visits the home page, submits the form and checks if there is a greeting on the page. Open test/kerodon_tutorial/features/greeting.clj
and add the new test:
(ns kerodon-tutorial.features.greeting
(:require [kerodon-tutorial.core.handler :refer [app]]
[kerodon.core :refer :all]
[kerodon.test :refer :all]
[clojure.test :refer :all]))
(deftest personalized-greeting
(-> (session app)
(visit "/")
(fill-in "Enter your name: " "John")
(press "Greet me!")
(within [:h1]
(has (text? "Hello John")))))
Run the tests suite:
lein test
When you run the tests with lein test
, you will see a failure field could not be found with selector "Enter your name: "
. Reason is that there’s no form on the homepage.
Let’s add the form to the homepage and the code that generates the greeting based on a user’s input. Open src/kerodon_tutorial/core/handler.clj
and add the new code:
(ns kerodon-tutorial.core.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[hiccup.core :refer [html]]
[ring.util.anti-forgery :refer [anti-forgery-field]]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]))
(defn homepage []
(html [:h1 "Welcome to Kerodon Tutorial"]
[:form {:action "/greeting" :method "post"}
(anti-forgery-field)
[:label {:for "name"} "Enter your name: "]
[:input {:type "text" :id "name" :name "name"}]
[:input {:type "submit" :value "Greet me!"}]]))
(defn greeting [name]
(html [:h1 (str "Hello " name)]))
(defroutes app-routes
(GET "/" [] (homepage))
(POST "/greeting" [name] (greeting name))
(route/not-found "Not Found"))
(def app
(wrap-defaults app-routes site-defaults))
We added few new things here:
- The homepage (
/
) now generates a form with “Enter your name: ” field and “Greet me!” button. We also generate a token that prevents cross-site request forgery with(anti-forgery-field
). If you leave out the token, you won’t be able to submit the form successfully as the request will be flagged as suspicious since it doesn’t have the token. - We have the new
POST /greeting
route that receives thename
parameter and callsgreeting
function passing the parameter. - And we have the
greeting
function that generates the greeting using the user’s input.
If you run the test again you will see that it pass:
Ran 1 tests containing 1 assertions.
0 failures, 0 errors.
If you run the server with lein ring server-headless
and visit http://localhost:3000
in the browser, you should see the form on the homepage.
Writing a custom Kerodon matcher
Let’s say we want to track all names that were entered to the form. We can track them by saving each name to a database. We don’t want to build user interface for the list of all users that submitted the form, so we want to check directly if a user name was successfully saved to the database.
Data repository
In the tutorial, we’re going to use in memory data storage – a vector wrapped by an atom with 2 functions for adding a user and finding all users. Open src/kerodon_tutorial/core/repository.clj
and add the following code:
(ns kerodon-tutorial.core.repository)
(def users (atom []))
(defn create-user [user]
(swap! users conj user))
(defn get-users [] @users)
In a real life web application, we can replace the atom with a persistent data storage, but keep the interface for handing data.
Implementing a matcher
Lets update the greeting test (test/kerodon_tutorial/features/greeting.clj
) to check if a user was saved after form was submitted:
(ns kerodon-tutorial.features.greeting
(:require [kerodon-tutorial.core.handler :refer [app]]
[kerodon.core :refer :all]
[kerodon.test :refer :all]
[clojure.test :refer :all]))
(deftest personalized-greeting
(-> (session app)
(visit "/")
(fill-in "Enter your name: " "John")
(press "Greet me!")
(within [:h1]
(has (text? "Hello John")))
(has (saved-user? "John"))))
You want be able to run the test as saved-user?
matcher doesn’t exit yet. We need to implement it.
A Kerodon matcher has the following form:
(defmacro my-matcher? [arg1 arg2 ...]
`(validate comparator
generator
expected
expected-message))
The matcher is a macro that can receive any number of arguments and it’s using another macro – validate
to perform the validation. validate
macro receives 4 arguments:
comparator
is a function that’s going to be used to compare theexpected
value with the return value ofgenerator
function.generator
is a function with one argument – the current state of the web application that user can see.generator
can use the state to produce a value that should be matched with theexpected
value.expected
is the value we expect to find in the current state of the application.expected-message
is the message that’s displayed to a developer when the test fails.
The state
parameter that’s passed to the generator
function is a Clojure map that contains useful info about the last request, response and the application. This is an example state
after performing POST /greeting
request in our application:
{:response {:status 200
:headers {...}
:body "<h1>Hello John</h1>"}
:request {:remote-addr "localhost"
:headers {...}
:server-port 80
:content-length 117
:content-type "application/x-www-form-urlencoded"
:uri "/greeting"
:server-name "localhost"
:query-string nil
:body ...
:scheme :http,
:request-method :post}
:headers nil
:enlive ({:tag :html
:attrs nil
:content ({:tag :body
:attrs nil
:content ({:tag :h1
:attrs nil
:content (Hello John)})})})
:app ...,
:content-type nil,
:cookie-jar {...}}
The most important part of the state
for a matcher that checks if some content is available on the page is the :enlive
content. It contains representation of the page as Clojure sequence. This is an example matcher from the Kerodon source code (tweaked a little bit to emphasize the use of state
parameter) that checks if a text is available on the page:
(defmacro text? [expected]
`(validate =
(fn [state#] (apply str (enlive/texts (:enlive state#))))
~expected
(~'text? ~expected)))
You can see that the generator
function of the matcher is using Enlive to select all text nodes from the :enlive
sequence in the state
parameter. Text nodes are then matched with =
function and expected
value.
For our saved-user?
matcher we won’t need to use the state
parameter, since the matcher will query the repository data directly. But, state
parameter is useful if you want to write domain specific matchers for your application. For example, instead of writing a test that will check if a text is displayed on a page, you can write a custom matcher that will check if a domain object – like a to-do item or a reservation, is visible on the page.
Let’s implement saved-user?
matcher. Open test/kerodon_tutorial/features/greeting.clj
and add the new matcher code:
(ns kerodon-tutorial.features.greeting
(:require [kerodon-tutorial.core.handler :refer [app]]
[kerodon-tutorial.core.repository :as repo]
[kerodon.core :refer :all]
[kerodon.test :refer :all]
[clojure.test :refer :all]))
(defmacro saved-user? [user]
`(validate =
(fn [state#] (last (repo/get-users)))
~user
(~'saved-user? ~user)))
(deftest personalized-greeting
(-> (session app)
(visit "/")
(fill-in "Enter your name: " "John")
(press "Greet me!")
(within [:h1]
(has (text? "Hello John")))
(has (saved-user? "John"))))
Let’s dissect saved-user?
matcher:
- The matcher has one parameter –
user
. We’re checking if the user was saved to the database. - We’re using
=
as comparator function to compare the expected user and the user that was saved to the database. generator
function has one argument –state
, but we’re not using it in the body. Instead,generator
finds the last user that was saved to the database.- The expected value is the user that we send to the matcher.
When you run the test again (lein test
) you will see that the test fails with:
FAIL in (personalized-greeting) (greeting.clj:22)
expected: (saved-user? "John")
actual: nil
We need to implement saving the user to the repository every time new greeting request is made. Open src/kerodon_tutorial/core/handler.clj
and update the code to save the user on every /greeting
request:
(ns kerodon-tutorial.core.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[hiccup.core :refer [html]]
[ring.util.anti-forgery :refer [anti-forgery-field]]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[kerodon-tutorial.core.repository :as repo]))
(defn homepage []
(html [:h1 "Welcome to Kerodon Tutorial"]
[:form {:action "/greeting" :method "post"}
(anti-forgery-field)
[:label {:for "name"} "Enter your name: "]
[:input {:type "text" :id "name" :name "name"}]
[:input {:type "submit" :value "Greet me!"}]]))
(defn greeting [name]
(repo/create-user name)
(html [:h1 (str "Hello " name)]))
(defroutes app-routes
(GET "/" [] (homepage))
(POST "/greeting" [name] (greeting name))
(route/not-found "Not Found"))
(def app
(wrap-defaults app-routes site-defaults))
When you run tests, you will see that they pass:
Ran 1 tests containing 2 assertions.
0 failures, 0 errors.
Wrapping up
Kerodon API provides helpers for interacting with web pages in tests. There are situations when we need custom matchers:
- If we want to check if a side effect happened, we can’t inspect the content of a web page and draw conclusions.
- When we want to make a more intuitive DSL for writing tests that will match our domain.
In the tutorial we showed anatomy of a Kerodon matcher and explained it’s parts. After that we explained how you can implement your own matcher that will help you write more robust and more readable tests.