I’d like to write a Clojure with-test-tags macro that wraps a bunch of forms, and adds some metadata to the name of each deftest form – specifically, add some stuff to a :tags key, so that I can play with a tool to run tests with a specific tag.
One obvious implementation for with-test-tags is to walk the entire body recursively, modifying each deftest form as I find it. But I’ve been reading Let Over Lambda recently, and he makes a good point: instead of walking the code yourself, just wrap the code in a macrolet and let the compiler walk it for you. Something like:
(defmacro with-test-tags [tags & body]
`(macrolet [(~'deftest [name# & more#]
`(~'~'deftest ~(vary-meta name# update-in [:tags] (fnil into []) ~tags)
~@more#))]
(do ~@body)))
(with-test-tags [:a :b]
(deftest x (...do tests...)))
This has the obvious problem, though, that the deftest macro continues to expand recursively forever. I could make it expand to clojure.test/deftest instead, thus avoiding any further recursive expansions, but then I can’t usefully nest instances of with-test-tags to label sub-groups of tests.
At this point, especially for something as simple as deftest, it looks like walking the code myself will be simpler. But I wonder if anyone knows a technique for writing a macro which “slightly modifies” certain subexpressions, without recursing forever.
For the curious: I considered some other approaches, such as having a compile-time binding-able var that I set as I go up and down the code, and using that var when I finally see a deftest, but since each macro only returns a single expansion its bindings won’t be in place for the next call to macroexpand.
Edit
I did the postwalk implementation just now, and while it works it doesn’t respect special forms such as quote – it expands inside of those as well.
(defmacro with-test-tags [tags & body]
(cons `do
(postwalk (fn [form]
(if (and (seq? form)
(symbol? (first form))
(= "deftest" (name (first form))))
(seq (update-in (vec form) [1]
vary-meta update-in [:tags] (fnil into []) tags))
form))
body)))
(Also, sorry for possible noise on the common-lisp tag – I thought you might be able to help out with weirder macro stuff even with minimal Clojure experience.)
(This is a new approach,
eval– andbinding-free. As discussed inthe comments on this answer, the use of
evalis problematic becauseit prevents tests from closing over the lexical environments they seem
to be defined in (so
(let [x 1] (deftest easy (is (= x 1))))nolonger works). I leave the original approach in the bottom half of the
answer, below the horizontal rule.)
The
macroletapproachImplementation
Tested with Clojure 1.3.0-beta2; it should probably work with 1.2.x as
well.
Usage
…is best demonstrated with a suite of (passing) tests:
Design notes:
We want to make the
macrolet-based design described in thequestion text work. We care about being able to nest
with-test-tagsand preserving the possibility of defining testswhose bodies close over the lexical environments they are defined
in.
We will be
macrolettingdeftestto expand to aclojure.test/deftestform with appropriate metadata attached tothe test’s name. The important part here is that
with-test-tagsinjects the appropriate tag set right into the definition of the
custom local
deftestinside themacroletform; once thecompiler gets around to expanding the
deftestforms, the tag setswill have been hardwired into the code.
If we left it at that, tests defined inside a nested
with-test-tagswould only get tagged with the tags passed to theinnermost
with-test-tagsform. Thus we havewith-test-tagsalsomacroletthe symbolwith-test-tagsitself behaving much likethe local
deftest: it expands to a call to the top-levelwith-test-tagsmacro with the appropriate tags injected into thetagset.
The intention is that the inner
with-test-tagsform inexpand to
(deftest-magic.core/with-test-tags #{:foo :bar} ...)(if indeed
deftest-magic.coreis the namespacewith-test-tagsis defined in). This form immediately expands into the familiar
macroletform, with thedeftestandwith-test-tagssymbolslocally bound to macros with the correct tag sets hardwired inside
them.
(The original answer updated with some notes on the design, some
rephrasing and reformatting etc. The code is unchanged.)
The
binding+evalapproach.(See also https://gist.github.com/1185513 for a version
additionally using
macroletto avoid a custom top-leveldeftest.)Implementation
The following is tested to work with Clojure 1.3.0-beta2; with the
^:dynamicpart removed, it should work with 1.2:Usage
Design notes
I think that on this occasion a judicious use of
evalleads to auseful solution. The basic design (based on the “
binding-able Var”idea) has three components:
A dynamically bindable Var —
*tags*— which is bound at compiletime to a set of tags to be used by
deftestforms to decorate thetests being defined. We add no tags by default, so its initial
value is
#{}.A
with-test-tagsmacro which installs an appropriate for*tags*.A custom
deftestmacro which expands to aletform resemblingthis (the following is the expansion, slightly simplified for
clarity):
<NAME>and<BODY>are the arguments given to the customdeftest, inserted in the appropriate spots through unquoting theappropriate parts of the syntax-quoted expansion template.
Thus the expansion of the custom
deftestis aletform in which,first, the name of the new test is prepared by decorating the given
symbol with the
:tagsmetadata; then aclojure.test/deftestformusing this decorated name is constructed; and finally the latter form
is handed to
eval.The key point here is that the
(eval form)expressions here areevaluated whenever the namespace their contained in is AOT-compiled or
required for the first time in the lifetime of the JVM running this
code. This is exactly the same as the
(println "asdf")in atop-level
(def asdf (println "asdf")), which will printasdfwhenever the namespace is AOT-compiled or required for the first
time; in fact, a top-level
(println "asdf")acts similarly.This is explained by noting that compilation, in Clojure, is just
evaluation of all top-level forms. In
(binding [...] (deftest ...),bindingis the top-level form, but it only returns whendeftestdoes, and our custom
deftestexpands to a form which returns whenevaldoes. (On the other hand, the wayrequireexecutes top-levelcode in already-compiled namespaces — so that if you have
(def tin your code, the value of(System/currentTimeMillis))
twilldepend on when you require your namespace rather than on when it was
compiled, as can be determined by experimenting with AOT-compiled code
— is just the way Clojure works. Use read-eval if you want actual
constants embedded in code.)
In effect, the custom
deftestruns the compiler (througheval) atthe run-time-at-compile-time of macro expansion. Fun.
Finally, when a
deftestform is put inside awith-test-tagsform,the
formof(eval form)will have been prepared with the bindingsinstalled by
with-test-tagsin place. Thus the test being definedwill be decorated with the appropriate set of tags.
At the REPL
And just to be sure working tests are being defined…