From Spec to Test Suite in Common Lisp: Mustache
Just a quick write-up for the holidays.
In this article, I will walk you through writing a test suite in Common Lisp based upon a specification of the software in question. The software we'll be looking at is the excellent mustache.
This is a fairly basic Lisp article, but I will be glossing over a lot of the details of the code I've written – partially because it should be fairly intuitive, partially because I have no idea how idiomatic it is. Take everything with a grain of salt, and enjoy the results.
Table of Contents
The Basics
Mustache was written as an example of 'logicless' templating: providing the bare minimum of functionality needed to create template documents that can be interpolated with data. In this scenario, 'bare minimum' is a complement: an explicit design decision to prevent spaghetti code in templates. Mustache templates essentially consist of three primitive constructs:
- The data construct: if data exists with the name given in the token, replace the token with the data.
- The loop construct: if the name points to data that is 'list-like', render this part of the template for each element in the list.
- The inverted construct: if the name points to data that is non-existent, or 'false-like', render something.
Nearly all features that Mustache provides can be narrowed down into one of these categories. It's elegantly simple, but implementations, depending on their language, may vary in difficulty to express.
I think it's interesting that if you ignore that Mustache was
designed for 'hash table'-like contexts, Common Lisp already
provides a complete implementation of Mustache with its FORMAT
directives, specifically the aesthetic, iteration, and conditional expression directives. Right out of the gate you can do most of what
you'd want to do in Mustache, as long as you use lists instead of
hash tables, and get used to the esoteric syntax. But this isn't
good enough for us: we want a rigorous implementation of the
Mustache language, and for that, we need to test our implementation
against the spec. And here's where it gets interesting.
Mustache's spec was written in YAML, and is also provided as JSON, making it machine-parseable. It is divided up into files. Each file is a discrete section of the spec. Each file contains an overview describing the file, and tests. Each test contains a name, description, context data, template, and expected result.
Using this information we can construct an automated test suite: one that provides our implementation as an input, uses the context and template in each test case, and compares it against the expected result. This is absolutely by design and a wonderful thought on the part of the Mustache 'working group' (for want of a term to describe the various contributors to the language).
Parsing the Spec
First things first. Let's grab a copy of the spec.
$ git clone git://github.com/mustache/spec.git ~/Projects/mustache.spec/
Pop open an REPL. I'm going to load the libraries I know we'll be using, in advance, as well as some helper variables and functions:
(ql:quickload '(fiveam cl-json cl-who)) ;; courtesy http://rosettacode.org/wiki/Walk_a_directory/Non-recursively#Common_Lisp (defun walk-directory (directory pattern) (directory (merge-pathnames pattern directory))) (setq *spec-directory* #P"~/Projects/mustache.spec/") (defun utf8-json-decode (pathname) (with-open-file (stream pathname :direction :input :external-format :utf-8) (json:decode-json-from-source stream)))
These forms should be fairly self-explanatory. We provide a helper function to glob over the spec files we want, set the spec directory to a top-level name, and mix our JSON parsing function with a helper that ensures the input stream is UTF-8.
So now:
CL-USER> (walk-directory #P"~/Projects/mustache.spec/" "specs/*.json") (#P"/Users/msnyder/Projects/mustache.spec/specs/comments.json" #P"/Users/msnyder/Projects/mustache.spec/specs/delimiters.json" #P"/Users/msnyder/Projects/mustache.spec/specs/interpolation.json" #P"/Users/msnyder/Projects/mustache.spec/specs/inverted.json" #P"/Users/msnyder/Projects/mustache.spec/specs/partials.json" #P"/Users/msnyder/Projects/mustache.spec/specs/sections.json" #P"/Users/msnyder/Projects/mustache.spec/specs/~lambdas.json") (setq *all-specs* (mapcar #'utf8-json-decode (walk-directory *spec-directory* "specs/*.json")))
Let's confirm each loaded file in the spec has the same basic structure.
CL-USER> (mapcar (lambda (x) (mapcar #'car x)) *all-specs*) ((:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS) (:----+ATTN-- :OVERVIEW :TESTS))
Brilliant.
Our Implementation
… sucks. No really. All it does is return the template, un-interpolated.
(defun mustache-render (template data) template)
But this will work for our purposes. All we need is something we can pass the arguments into, and get a result. It doesn't have to be the right result, just yet.
The Test Suite
FiveAM is my go-to unit test library for Common Lisp. It's simple, elegant, and designed to provide test results in a format that can easily be transformed for any purpose.
What we'd like to do is generate this test suite by iterating over each test in the spec, and creating a unit test for it.
To give you an idea of the basic structure of a test, here's one of the imported tests from the spec.
;; courtesy http://aima.cs.berkeley.edu/lisp/utilities/utilities.lisp (defun random-element (list) "Return some element of the list, chosen at random." (nth (random (length list)) list)) CL-USER> (random-element (cdr (assoc :tests (random-element *all-specs*)))) ((:NAME . "Falsey") (:DATA (:BOOLEAN)) (:EXPECTED . "\"This should be rendered.\"") (:TEMPLATE . "\"{{^boolean}}This should be rendered.{{/boolean}}\"") (:DESC . "Falsey sections should have their contents rendered."))
Versus its counterpart in the YAML spec:
- name: Falsey desc: Falsey sections should have their contents rendered. data: { boolean: false } template: '"{{^boolean}}This should be rendered.{{/boolean}}"' expected: '"This should be rendered."'
It's hard to see from this example, but our JSON importer turned the
data context into an association list, which we should use in the
implementation as the type for our context argument. In this case,
(cdr (assoc :boolean (cdr (assoc :data test)))) would return nil,
a 'falsey' value.
So for each spec, we have a bunch of tests. For each test, we want to make a unit test in our test suite. Simple enough.
(fiveam:def-suite :mustache-specs) (fiveam:in-suite :mustache-specs) (loop for spec in *all-specs* do (loop for test in (cdr (assoc :tests spec)) do (let ((name (cdr (assoc :name test))) (desc (cdr (assoc :desc test))) (data (cdr (assoc :data test))) (template (cdr (assoc :template test))) (expected (cdr (assoc :expected test)))) (fiveam:test name desc (fiveam:is (string= expected (mustache-render template data)))))))
Try it out.
CL-USER> (fiveam:run :mustache-specs) ; in: LAMBDA () ; (MUSTACHE-RENDER TEMPLATE DATA) ; ; caught WARNING: ; undefined variable: DATA ; (LAMBDA () ; DESC ; (IT.BESE.FIVEAM:IS (STRING= EXPECTED (MUSTACHE-RENDER TEMPLATE DATA)))) ; ; caught WARNING: ; undefined variable: DESC ; (IT.BESE.FIVEAM:IS (STRING= EXPECTED (MUSTACHE-RENDER TEMPLATE DATA))) ; ==> ; (LET ((#:E-0 EXPECTED) (#:A-1 (MUSTACHE-RENDER TEMPLATE DATA))) ; (IF (PROGN (STRING= #:E-0 #:A-1)) ; (IT.BESE.FIVEAM::ADD-RESULT 'IT.BESE.FIVEAM::TEST-PASSED :TEST-EXPR ; '(STRING= EXPECTED ; (MUSTACHE-RENDER TEMPLATE DATA))) ; (IT.BESE.FIVEAM::PROCESS-FAILURE :REASON ; (FORMAT NIL ; "~S evaluated to ~S, which is not ~S to ~S." ; '(MUSTACHE-RENDER TEMPLATE ; DATA) ; #:A-1 'STRING= #:E-0) ; :TEST-EXPR ; '(STRING= EXPECTED ; (MUSTACHE-RENDER TEMPLATE ; DATA))))) ; ; caught WARNING: ; undefined variable: EXPECTED ; (MUSTACHE-RENDER TEMPLATE DATA) ; ; caught WARNING: ; undefined variable: TEMPLATE ; ; compilation unit finished ; Undefined variables: ; DATA DESC EXPECTED TEMPLATE ; caught 4 WARNING conditions X (#<IT.BESE.FIVEAM::UNEXPECTED-TEST-FAILURE {10037A5781}>)
What the hell happened? Why is there only one test? Why were all those variables in the loop considered undefined?
Well. FiveAM's test form is a macro. It is evaluated and
expanded before the rest of the code, and, in this case, evaluated
when none of the variables used in it are actually bound to a
value. This means that for every test in the spec, we created a test
called 'name', instead of a test called whatever the 'name' variable
pointed to. So we are out of luck, in terms of this approach.
But it doesn't mean we're out of luck, period. Knowing that test
is a macro, we can reformulate our problem. We don't want to iterate
over the specs and tests, and create test cases for each one. We
want to write a macro which expands into code which does that. And
we can.
(fiveam:def-suite :mustache-specs) ; redefining a test suite empties it (fiveam:in-suite :mustache-specs) (defmacro mustache-spawn-test-suite (specs) `(progn ,@(loop for spec in (eval specs) append (loop for test in (cdr (assoc :tests spec)) for name = (cdr (assoc :name test)) for template = (cdr (assoc :template test)) for data = (cdr (assoc :data test)) for expected = (cdr (assoc :expected test)) for desc = (cdr (assoc :desc test)) collect `(fiveam:test ,(intern name) ,desc (fiveam:is (string= ,expected (mustache-render ,template ,data))))))))
Our macro doesn't look too different from our first attempt, but what it does is something quite wonderful.
CL-USER> (macroexpand '(mustache-spawn-test-suite *all-specs*)) (PROGN (IT.BESE.FIVEAM:TEST |Inline| "Comment blocks should be removed from the template." (IT.BESE.FIVEAM:IS (STRING= "1234567890" (MUSTACHE-RENDER "12345{{! Comment Block! }}67890" NIL)))) (IT.BESE.FIVEAM:TEST |Multiline| "Multiline comments should be permitted." (IT.BESE.FIVEAM:IS (STRING= "1234567890 " (MUSTACHE-RENDER "12345{{! This is a multi-line comment... }}67890 " NIL)))) ;; ...
By writing a macro, we've written a tiny amount of code which generates a lot of code. This code does precisely what we want: iterates over the list of tests in the Mustache spec, and creates a test suite for each and every one of them.
(Lispers frown on the use of eval as above. Can you rewrite
the macro to avoid its use?)
Now we can execute the macro, passing in our list of specifications, and generate the test suite that we really want.
(mustache-spawn-test-suite *all-specs*)
Reporting Results
When we use fiveam:run! to run the test suite, we get back results
in a very nice printed format. This works well if you're at the
REPL, but what if you wanted something to display to the world?
Something half-continuous integration, half-implementation progress
bar? Using fiveam:run and the excellent CL-WHO, we can do just
that.
First, run the tests.
(setq *results* (fiveam:run :mustache-specs))
Then, let's provide a simple transformation of the test results.
(defun pretty-result (test-result) (flet ((result-type (result) (format nil "~(~A~)" (symbol-name (type-of result))))) (let ((test-case (fiveam::test-case test-result))) (list (symbol-name (fiveam::name test-case)) (fiveam::description test-case) (result-type test-result))))) ;; then, try it out: CL-USER> (pretty-result (nth 0 *results*)) ("Inline" "Comment blocks should be removed from the template." "test-failure")
Perfect. Running pretty-result on one of the test results produces
a simple list consisting of the name of the test, the description of
the test, and a token representing a passed/failed/skipped test.
Now just wrap it in some CL-WHO, fire up Emacs' httpd-server, and navigate over to the generated HTML file.
(with-open-file (stream #P"~/public_html/results.html" :direction :output :if-exists :supersede) (cl-who:with-html-output (stream) (:style :type "text/css" ".test-passed { background-color: #0f0; }" ".test-failure { background-color: #f00; }" ".unexpected-test-failure { background-color: #ff0; }") (:table (loop for (name description result) in (mapcar #'pretty-result *results*) do (cl-who:htm (:tr (:td (cl-who:fmt name)) (:td (cl-who:fmt description)) (:td :class result (cl-who:fmt result))))))))
Pretty impressive, yeah? Fifty source lines of code from start to
finish, including our non-existent implementation of
mustache-render. At any point that the definition of
mustache-render changes, all you need to do to re-generate the test
suite results is re-run (setq *results* (fiveam:run :mustache-specs)) and then the above snippet of
CL-WHO-infused Lisp.
And there you have it! The next step, of course, is to write a Lisp package that meets the spec, and causes all those red table cells in the generated output to turn green. But, as a wise computer science book once asserted, "this is left as an exercise to the reader."