Fixtures as Caches

I am responsible — for better or for worse — for the library which eventually became clojure.test. It has remained largely the same since it was first added to the language distribution back in the pre-1.0 days. While there are many things about clojure.test which I would do differently now — dynamic binding, var metadata, side effects — it has held up remarkably well.

I consider fixtures to be one of the less-well-thought-out features of clojure.test. A clojure.test fixture is a function which wraps a test function, typically for the purpose of setting up and tearing down the environment in which the test should run. Because test functions do not take arguments, the only way for a fixture to pass state to the test function is through dynamic binding. A typical fixture looks like this:

 (ns fixtures-example
   (:require [clojure.test :as test :refer [deftest is]]))
 
 (def ^:dynamic *fix*)
 
 (defn my-fixture [test-fn]
   (println "Set up *fix*")
   (binding [*fix* 42]
     (test-fn))
   (println "Tear down *fix*"))
 
 (test/use-fixtures :each my-fixture)
 
 (deftest t1
   (println "Do test t1")
   (is (= *fix* 42)))
 
 (deftest t2
   (println "Do test t2")
   (is (= *fix* (* 7 6))))

There are two kinds of fixtures in clojure.test:

:each fixtures run once per test, for every test in the namespace.

:once fixtures run once per namespace, wrapped around all tests in that namespace.

I think the design of fixtures has a lot of problems. Firstly, attaching them to namespaces was a bad idea, since namespaces typically contain many different tests, only some of which actually need the fixture. This increases the likelihood of unintended coupling between fixtures and test code.

Secondly, :each fixtures are redundant. If you need to wrap every test in some piece of shared code, all you need to do is put the shared code in a function or macro and call it in the body of each test function. There’s a small amount of duplication, but you gain flexibility to add tests which do not use the same shared code.

(Another common complaint about fixtures is that they make it difficult to execute single tests in isolation, although the addition of test-vars in Clojure 1.6 ameliorated that problem.)

So :once fixtures are the only ones that matter. But if you want true isolation between your tests then they should not share any state at all. The only reason for sharing fixtures across tests is when the fixture does something expensive or time-consuming. Here again, namespaces are often the wrong level of granularity. If some resource is expensive to prepare, you may only want to pay the cost of preparing it once for all tests in your project, not once per namespace.

So the purpose of :once fixtures is to cache their initialized state in between tests. What if we were to use fixtures only for caching? It might look something like this:

 (ns caching-example
   (:require [clojure.test :refer [deftest is]]))
 
 (def ^:dynamic ^:private *fix* nil)
 
 (defn new-fix
   "Computes a new 'fix' value for tests."
   []
   (println "Computing fixed value")
   42)
 
 (defn fix
   "Returns the current 'fix' value for
   tests, creating one if needed."
   []
   (or *fix* (new-fix)))
 
 (defn fix-fixture
   "A fixture function to provide a reusable
   'fix' value for all tests in a namespace."
   [test-fn]
   (binding [*fix* (new-fix)]
     (test-fn)))
 
 (clojure.test/use-fixtures :once fix-fixture)
 
 (deftest t1
   (is (= (fix) 42)))
 
 (deftest t2
   (is (= (fix) (* 7 6))))

This still avoids repeated computation of the fix value, but clearly shows exactly which tests use it. The :once fixture is just an optimization: You could remove it and the tests would still work, perhaps more slowly. Best of all, you can run the individual test functions in the REPL without any additional setup.

The same idea works even if the fixture requires tear-down after tests are finished:

 (ns resource-example
   (:require [clojure.test :refer [deftest is]]))
 
 (defn acquire-resource []
   (println "Acquiring resource")
   :the-resource)
 
 (defn release-resource [resource]
   (println "Releasing resource"))
 
 (def ^:dynamic ^:private *resource* nil)
 
 (defmacro with-resource
   "Acquires resource and binds it locally to
   symbol while executing body. Ensures resource
   is released after body completes. If called in
   a dynamic context in which *resource* is
   already bound, reuses the existing resource and
   does not release it."
   [symbol & body]
   `(let [~symbol (or *resource*
                      (acquire-resource))]
      (try ~@body
           (finally
             (when-not *resource*
               (release-resource ~symbol))))))
 
 (defn resource-fixture
   "Fixture function to acquire a resource for all
   tests in a namespace."
   [test-fn]
   (with-resource r
     (binding [*resource* r]
       (test-fn))))
 
 (clojure.test/use-fixtures :once resource-fixture)
 
 (deftest t1
   (with-resource r
     (is (keyword? r))))
 
 (deftest t2
   (with-resource r
     (is (= "the-resource" (name r)))))
 
 (deftest t3
   (with-resource r
     (is (nil? (namespace r)))))

Again, each of these tests can be run individually at the REPL with no extra ceremony. If you don’t want to keep paying the resource-setup cost in the REPL, you could temporarily redefine the *resource* var in its initialized state.

The key in both cases is that the “fixtures” are designed to nest without duplicating effort. Each test function specifies exactly what state or resources it needs, but only creates them if they do not already exist. Some of those resources may be shared among multiple tests, but that fact is hidden from the individual tests.

With this in mind, it becomes possible to share a resource across all tests in a project, not just within a namespace. All you need is an “entry point” which kicks off all the tests. clojure.test provides run-tests for specifying individual namespaces and run-all-tests to search for namespaces by regex. All you have to do is make sure your test namespaces are loaded, either via direct require or a utility such as tools.namespace. Then you can run a full test suite that only executes the expensive setup/teardown code once:

 (ns main-test
   (:require [clojure.test :as test]
             [my.app.a-test]))
 
 (defn -main [& _]
   (with-resource-1
     (with-resource-2
       ;;; ... more fixture wrappers ...
       (test/run-all-tests #"^my\.app\..+-test$"))))