How to write a custom Kerodon matcher

Kerodon provides a friendly Clojure API for interacting with web pages in tests. Learn how to write a custom matcher when Kerodon’s API is not enough.

Brought to you by

Semaphore

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:

Example application

It the tutorial we will use Personalized Greeting web application.

It displays a form on the homepage:

Homepage Form

And a friendly greeting after submitting the form:

Personalized Greeting

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 the name parameter and calls greeting 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 the expected value with the return value of generator 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 the expected 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.

81dc6254e4ae1e5578bfba393a844bd4
Nebojša Stričević

An independent Ruby on Rails and JavaScript consultant. Visit my website for more info.

on this tutorial so far.
User deleted author {{comment.createdAt}}

Edited on {{comment.updatedAt}}

Cancel

Sign In You must be logged in to comment.