symbol-macrolet and inside-out test fixtures
Rolling right along with more ways to write fixtures, let’s say we start with a test that looks like this:
(deftest test-convoluted
(with-connection hdb
(try
(do-commands "create table person (name varchar(255))")
(insert-values :person [:name] ["bill"] ["joey"])
(with-query-results results ["select name from person"]
(is (= "bill" (:name (first results))))
(is (= "joey" (:name (second results)))))
(finally
(do-commands "drop schema public cascade")))))
It does it’s setup and teardown inside of the deftest itself. The actual “testing” parts are the two (is)
forms deeply nested inside. I’ve already written a post on how the clojure.test
lib that comes with Clojure addresses this kind of complexity by providing fixtures. However, they’re a bit clumsy and aren’t particularly fine-grained. You can either run them once before (and after) all tests run, or once before and after each test runs.
The Common Lisp testing framework FiveAM has a different approach to defining and using fixtures. The FiveAM library defines two macros, def-fixture
and with-fixture
which define a fixture form and execute forms inside of a named fixture, respectively.
To define a fixture that takes care of the setup and teardown in the test above, we would write something like this:
(def-fixture person-schema []
(with-connection hdb
(try
(do-commands "create table person (name varchar(255), age integer)")
(insert-values :person [:name] ["bill"] ["joey"])
(with-query-results results [query]
(test-body))
(finally
(do-commands "drop schema public cascade")))))
In the above, test-body
is a special captured variable that will be replaced with whatever forms you later specify. It’s where the meat of your test will go. You specify what to run there as the body of the with-fixture
macro, thusly:
(deftest test-ideal
(with-fixture person-schema []
(is (= "bill" (:name (first results))))
(is (= "joey" (:name (second results)))))))
The with-fixture
form names the fixture we want to use, and takes care of expanding it in such a way that the variables are still checked by the compiler. If our fixture didn’t declare results in the scope that our assertions were expanded to, the compiler would complain just as if we had written the whole thing out by hand.
We can make the fixture in our example even more flexible. def-fixture
can declare arguments and with-fixture
can provide them. Altering our setup slightly to add a second column, we can then pass in a specific query to be run per fixture:
(def-fixture person-schema [query]
(with-connection hdb
(try
(do-commands "create table person (name varchar(255), age integer)")
(insert-values :person [:name :age] ["bill" 25] ["joey" 35])
(with-query-results results [query]
(test-body))
(finally
(do-commands "drop schema public cascade")))))
And then we can get a lot more mileage out of our solitary fixture:
(deftest test-ideal
(testing "name column"
(with-fixture person-schema ["select name from person"]
(is (= "bill" (:name (first results))))
(is (= "joey" (:name (second results))))))
(testing "age column"
(with-fixture person-schema ["select age from person"]
(is (= 25 (:age (first results))))
(is (= 35 (:age (second results)))))))
This is a trick that defmacro
can’t easily perform. If we wanted to define person-schema
as a macro, in order to capture results
, we’d have to write put ~'results
somewhere in a backtick form. It’ll work, but for any significant number of capturing symbols, there’s tildes and ticks everywhere. For a feature that would ostensibly have users writing lots and lots of their own expansions, that’s a major drawback, in my opinion. Early versions of the newly re-written ClojureQL had users do this, I believe, with little snippets of macros sprinkled throughout their code. It turns out that we can have our cake and eat it too, and you’ve probably guessed how from the title of this post.
I copped the implementation straight from FiveAM. Common Lisp has an advantage here, as the language has built-in local macros (Clojure’s are global to a namespace) and symbol macros, which FiveAM uses to great effect. However, Konrad Hinsen (of clojure.contrib.monad
fame) has implemented local and symbol macros in the clojure.contrib.macro-utils
lib. I used that.
My Clojure implementation consists of the two macros and a global dynamic variable that holds an atom mapping fixture names to their arguments and bodies. The def-fixture
macro takes care of assoc
-ing them as they are defined. The with-fixture
macro pulls that fixture definition and constructs an anonymous function from it, as well as making test-body
a symbol macro that expands to the given body of with-fixture
.
(def *fixtures* (atom {}))
(defmacro def-fixture [name args & body]
`(swap! *fixtures* assoc '~name (cons '~args '~body)))
(defmacro with-fixture [name args & body]
(let [[largs lbody] (get @*fixtures* name)]
`(symbol-macrolet [~'test-body (fn [] ~@body)]
((fn ~largs ~lbody)
~@args))))
I could have used a local macro, by swapping the symbol-macrolet
form for a macrolet
form, but then I would have had to quote the body
parameter passed to with-fixture
. By using a symbol macro and asking that users treat it like a fn, I can avoid that. It’s a small thing and either way works. For 8 lines of code overall, these 2 macros add a lot of flexibility to how you can define test fixtures.
leave a comment