DEV Community

Cover image for Spying and stubbing in Clojure and ClojureScript with test-doubles
Manuel Rivero
Manuel Rivero

Posted on • Edited on • Originally published at codesai.com

Spying and stubbing in Clojure and ClojureScript with test-doubles

As you may know from a previous post I’m working for GreenPowerMonitor
as part of a team that is developing a challenging SPA to monitor and manage renewable energy portfolios using ClojureScript.

We were dealing with some legacy code that was effectful and needed to be tested using test doubles,
so we explored some existing ClojureScript libraries but we didn't feel comfortable with them. On one hand, we found that some of them had different macros for different types of test doubles and this made tests that needed both spies and stubs become very nested. We wanted to produce tests with as little nesting as possible. On the other hand, being used to Gerard Meszaros’ vocabulary for tests doubles, we found the naming used for different types of tests doubles in some of the existing libraries a bit confusing. We wanted to stick to Gerard Meszaros’ vocabulary for tests doubles.

So we decided we'd write our own stubs and spies library.

We started by manually creating our own spies and stubs during some time so that we could identify the different ways in which we were going to use them. After a while, my colleague André Stylianos Ramos and I wrote our own small DSL to create stubs and spies using macros to remove all that duplication and boiler plate. The result was a small library that we've been using in our ClojureScript project for nearly a year and that we've recently adapted to make it work in Clojure as well:

(ns greenpowermonitor.test-doubles
  #?(:cljs (:require-macros
             [greenpowermonitor.test-doubles])))

(def ^:dynamic *spies-atom* (atom {}))

(defn make-spy-fn []
  (let [func-atom (atom [])]
    (letfn [(spy-fn [& args]
              (swap! func-atom conj (vec args)))]
      (swap! *spies-atom* conj {spy-fn func-atom})
      spy-fn)))

(defn make-mult-calls-stub-fn [values]
  (let [values-atom (atom values)]
    (fn [& _]
      (if (empty? @values-atom)
        (throw (ex-info "Too many calls to stub"
                        {:causes :calls-exceeded
                         :provided-return-values values}))
        (let [value (first @values-atom)]
          (swap! values-atom rest)
          value)))))

(defn make-stub-fn [option args]
  (case option
    :maps (fn [& fn-args]
            (let [call-args (vec fn-args)]
              (get args call-args (:any args))))
    :returns (make-mult-calls-stub-fn args)
    :constantly (constantly args)
    (throw (ex-info "Using :stubbing with an unknown option"
                    {:cause :unknown-option
                     :used-option option
                     :available-options [:maps :returns :constantly]}))))

#?(:clj
   (defn- create-spying-list [functions]
     (mapcat #(vector % `(make-spy-fn)) functions)))

#?(:clj
   (defn- create-stubbing-list [functions]
     (->> functions
          (partition 3)
          (mapcat (fn [[func key values]] [func `(make-stub-fn ~key ~values)])))))

#?(:clj
   (defn- create-ignoring-list [functions]
     (mapcat #(vector % `(constantly nil)) functions)))

#?(:clj
   (defn- create-doubles-list [spying stubbing ignoring]
     (vec (concat (create-spying-list spying)
                  (create-stubbing-list stubbing)
                  (create-ignoring-list ignoring)))))

#?(:clj
   (defn- extract-with-double-args [args]
     (let [double-def? #(or (vector? %) (keyword? %))]
       (split-with double-def? args))))

#?(:clj
   (defmacro with-doubles [& args]
     (let [[doubles body] (extract-with-double-args args)
           {:keys [spying stubbing ignoring]
            :or {spying [] stubbing [] ignoring []}} doubles]
       `(with-redefs ~(create-doubles-list spying stubbing ignoring)
          ~@body
          (reset! *spies-atom* {})))))

(defn calls-to [function]
  (if-let [calls (some-> *spies-atom* deref (get function) deref)]
    calls
    (let [error-msg "Attempting to check calls for a function that is not being spied on"]
      (throw #?(:clj  (Exception. error-msg)
                :cljs (js/Error. error-msg))))))
Enter fullscreen mode Exit fullscreen mode

I’m really glad to announce that GreenPowerMonitor has open-sourced our small spying and stubbing library for Clojure and ClojureScript: test-doubles.

In the following example written in ClojureScript, we show how we are using test-doubles to create two stubs (one with the :maps option and another with the :returns option) and a spy:

(ns greenpowermonitor.test-doubles.example
  (:require
   [clojure.test :refer [deftest testing is]]
   [greenpowermonitor.test-doubles :as td] 
   [horizon.common.ajax.api :as service]
   [horizon.common.config :as c]
   [horizon.common.state.lens :as l]
   [horizon.domain.maintenance.work-orders.member :as sut]
   [horizon.domain.rim :as domain.rim]))

(deftest saving-changes
  (let [some-api-url "some-url"]
    (td/with-doubles
      :stubbing [l/view :maps {[domain.rim/rim-wo-edit-changes-lens] {:interventions {53 7}}
                               [domain.rim/rim-wo-edit-state-id-lens] 1
                               [domain.rim/rim-wo-translated-lens] {:interventions [{:value 1
                                                                                     :state-id 1
                                                                                     :id 53}]}}
                 c/mk-work-orders-save-url :returns [some-api-url]]
      :spying [service/put]

      (sut/save-changes!)

      (is (= 1 (-> service/put td/calls-to count)))   
      (let [[url data] (-> service/put td/calls-to first)]
        (is (= some-api-url url))
        (is (= {:state-id 1
                :id 3
                :values [{:id 53 :value "7"}]}
               (:json-params data)))))))
Enter fullscreen mode Exit fullscreen mode

We could show you more examples here of how test-doubles can be used and the different options it provides, but we’ve already included a lot of explained examples in its documentation.

Please do have a look and try our library. You can get its last version from Clojars. We hope it might be as useful to you as it has been for us.

Top comments (0)