A small spying and stubbing library for Clojure and ClojureScript.
Add [greenpowermonitor/test-doubles "0.1.2"]
to [:profiles :dev :dependencies]
in your project.clj
.
with-doubles
takes a function symbol and replaces it in the scope of its body.
Be aware that with-doubles
only works with functions. It won't work with macros or special forms. 1
You can use the :stubbing
option inside the with-doubles
macro to stub calls to any function included in the vector that appears right after the :stubbing
keyword.
There are three different ways of stubbing a function.
This option makes a function return a given sequence of values in successive calls.
In the following example2, we stub the rand
function to make it return four values in sequence, 1
, 4
, 6
, 3
. When you call rand
more times than the number of values in the provided sequence, an exception gets thrown (see the message and data in the exception at the end of this test).
(ns greenpowermonitor.test-doubles.stubbing-with-returns-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(deftest stubbing-functions-using-returns
(testing "make a function return a given sequence of values in successive calls"
(td/with-doubles
:stubbing [rand :returns [1 4 6 3]]
(is (= 1 (rand)))
(is (= 4 (rand)))
(is (= 6 (rand)))
(is (= 3 (rand)))
(is (thrown? js/Error (rand)))
(try
(rand)
(catch :default e
(is (= "Too many calls to stub" (ex-message e)))
(is (= {:causes :calls-exceeded :provided-return-values [1 4 6 3]}
(ex-data e))))))))
This option makes a function always return the same value.
In the following example, we stub the rand
function so that it always returns 1.
(ns greenpowermonitor.test-doubles.stubbing-with-constantly-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(deftest stubbing-functions-using-constantly
(testing "make a function return always the same value"
(td/with-doubles
:stubbing [rand :constantly 1]
(is (= 1 (rand)))
(is (= 1 (rand)))
(is (= 1 (rand)))
(is (= 1 (rand))))))
This option makes a function return specific outputs for specific inputs.
In the following example, we stub the do-some-computation-fn
function so that it returns:
::output-for-args-1-and-2
when called with the arguments1
and2
.::output-for-args-2-and-3
when called with the arguments2
and3
.::output-for-any-other-arguments
when called with any other arguments.
and the constantly
function so that it returns:
30
when called with the argument1
40
when called with the argument2
Notice how, in this last case, if you don't explicitly provide a value for any other arguments (using the keyword :any
),
a nil
will be returned when the received parameters don't match any of the map keys.
(ns greenpowermonitor.test-doubles.stubbing-with-maps-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(defn do-some-computation-fn [_ _])
(deftest stubbing-functions-using-maps
(testing "make a function return specific outputs for specific inputs"
(td/with-doubles
:stubbing [do-some-computation-fn :maps {[1 2] ::output-for-args-1-and-2
[2 3] ::output-for-args-2-and-3
:any ::output-for-any-other-arguments}
constantly :maps {[1] 30
[2] 40}]
(is (= ::output-for-args-1-and-2 (do-some-computation-fn 1 2)))
(is (= ::output-for-args-2-and-3 (do-some-computation-fn 2 3)))
(is (= ::output-for-any-other-arguments (do-some-computation-fn 5 6)))
(is (= 30 (constantly 1)))
(is (= 40 (constantly 2)))
(is (= nil (constantly 6))))))
You can use the :spying
option inside with-doubles
macro to spy on all the calls to the functions included in the vector after :spying
keyword. To check the calls to the spied functions and the arguments passed in each call, you have to use the calls-to
function.
In the following example, we spy the calls to some-function
and println
functions. Then we call twice some-function
and three times greetings-function
(which calls println
).
Finally, we use calls-to
function to check the calls to each spied function and the arguments passed to them in each call.
Notice that if we erroneously used calls-to
on a function that was not being spied, it'd throw an exception to let us know.
(ns greenpowermonitor.test-doubles.spying-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(defn- some-function [a b]
(println a b))
(defn- greetings-function []
(println "Hola!"))
(deftest spying-functions
(td/with-doubles
:spying [some-function
println]
(some-function "koko" "triki")
(some-function "miko" "miki")
(greetings-function)
(greetings-function)
(greetings-function)
(is (= 2 (-> some-function td/calls-to count)))
(is (= [["koko" "triki"] ["miko" "miki"]] (td/calls-to some-function)))
(is (= ["koko" "triki"] (-> some-function td/calls-to first)))
(is (= 3 (-> println td/calls-to count)))
(is (->> println td/calls-to (every? #(= % ["Hola!"]))))
(try
(td/calls-to greetings-function)
(catch :default e
(is (= "Checking calls to not spied function"
(ex-message e)))))))
You can use the :ignoring
option inside with-doubles
macro to ignore all the calls to the functions included in the vector after :ignoring
keyword.
In the following example, even though you call the double-print-x-and-greet
function inside with-doubles
, nothing gets printed on the console because greetings-function
and print
get ignored.
(ns greenpowermonitor.test-doubles.stubbing-with-returns-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(defn- greetings-function []
(println "Hola!"))
(deftest ignoring-functions
(let [double-and-greet (fn [x] (print x) (greetings-function) (* 2 x))]
(is (= "2Hola!\n" (with-out-str (double-and-greet 2))))
(td/with-doubles
:ignoring [greetings-function
print]
(is (= "" (with-out-str (double-and-greet 2)))))))
You can use the :throwing
option inside with-doubles
macro to make all calls to the specified functions throw exceptions with the message and data you want.
(ns greenpowermonitor.test-doubles.stubbing-with-returns-examples
(:require
[clojure.test :refer [deftest testing is]]
[greenpowermonitor.test-doubles :as td]))
(deftest making-functions-throw-exceptions
(let [message "message"
some-map {:cause :some-cause}]
(td/with-doubles
:throwing [println {:message message :ex-data some-map}]
(try
(println "something")
(is false "should have thrown!")
(catch :default e
(is (= message (ex-message e)))
(is (= some-map (ex-data e))))))))
You can use as many test doubles as you want inside with-doubles
macro.
In the following example, we are using two stubs (one with :maps
option and another with :constantly
option) and a spy.
(ns greenpowermonitor.test-doubles.combining-different-test-doubles
(:require
[clojure.test :refer [deftest is]]
[greenpowermonitor.test-doubles :as td]))
(defn employee-info [_])
(defn today [])
(defn birthday-greetings [ids]
(let [employees-to-greet (->> ids
(map employee-info)
(filter #(= (today) (:birth-date %))))]
(doseq [employee employees-to-greet]
(println (str "Happy birthday " (:name employee) "!")))))
(deftest greeting-employees-whose-birthday-is-today
(td/with-doubles
:stubbing [employee-info :maps {[1] {:name "Koko" :birth-date {:day 1 :month 2}}
[2] {:name "Loko" :birth-date {:day 13 :month 12}}}
today :constantly {:day 1 :month 2}]
:spying [println]
(birthday-greetings [1 2])
(is (= [["Happy birthday Koko!"]] (td/calls-to println)))))
We were dealing with some legacy code that was effectful and needed to be tested, so we explored some existing ClojureScript libraries but we didn't feel comfortable with them. stubadub was a great candidate and we gave it a try, but its usage introduced a lot of nesting when stubbing and/or spying multiple functions. It also mixed the concept of spies and stubs into one which was confusing to us because we were used to Gerard Meszaros’ vocabulary for tests doubles.
What we wanted to achieve with this library was to:
- Provide a small DSL to create spies and stubs that followed Gerard Meszaros’ vocabulary for tests doubles.
- Avoid having different macros for each test double type to produce tests with as little nesting as possible.
test-doubles
uses with-redefs
under the hood, so the redefinition of any function you're stubbing or spying is not thread-local. This means that test-doubles
won't work if tests are run in parallel or if used to test asynchronous code.
1. Notice that some verbs that are functions in Clojure might be macros in ClojureScript or viceversa. For instance +
is a function in Clojure, but a macro in ClojureScript.
2. All the examples in this document are written in ClojureScript, due to the usage of interop such as js/Error
. Changing them to run in Clojure will require using the Clojure equivalents.
Copyright © 2018 GreenPowerMonitor
Distributed under the Eclipse Public License version 1.0.