If you read my blog you've probably heard "code is data, data is code" and at one time and you've looked up homoiconicity. You may have deeply understood the idea the first time you heard it; I definitely did not. However, a recent addition to expectations opened my eyes to how truly powerful this programming language property can be.
I'll start by admitting what I heard when I originally encountered homoiconicity. Stuart Halloway had begun promoting Clojure, and homoiconicity was one of the advantages he noted. I hit the wikipedia page, digested the words "code is data, data is code", and thought to myself: well, yeah, obviously. I'd spent plenty of time working with DSLs in Ruby, and I had plenty of experience evaluating code in various contexts. I thought something along the lines of: So you capture the code as data and evaluate it wherever it makes sense, I don't see the big deal. In short, I didn't get it.
Fast forward a few years and several hours of full time Clojure development and you'll find me adding interaction based testing to expectations. What I had in mind for testing interactions was simple, I want to write exactly the same thing for the test as what I write for the production code. Additionally, I want the format of the test to follow the same format that is used for state based testing:
(expect expected actual)
Once I had a clear vision for my requirements, the format of the tests became easy to visualize.
Assume I have a function that prints to standard out, and I want to test that this print occurs.
(defn print-it [it] (println it)) ;;; the test needs to be in the form (expect expected actual), ;;; and the line we're testing is (println it) ;;; so you could envision a test similar to the one below (expect (println 5) (print-it 5))
The above test looks great, but
(println 5) will be evaluated, return nil, and use nil as the expected value. I needed some way for the programmer to tell the testing framework that this was an interaction test, and expectations needed to verify that the function was called with the specified parameters. After trying a few different formats, I settled on the following solution.
(defn print-it [it] (println it)) ;;; the test needs to be in the form (expect expected actual), ;;; and the line we're testing is (println it) ;;; so you could envision a test similar to the one below (expect (interaction (println 5)) (print-it 5))
By wrapping the interaction I wanted to test with
(interaction ...), I created an easy way to identify and capture the function and arguments that needed to be verified.
Once I'd decided on the syntax, I went about the task of adding support to expectations. If you dug into the implementation of expectations, you'd find that expect is a macro that delegates the handling of the "expected" and "actual" arguments to the doexpect macro. The first thing the doexpect macro does is check if expected is a list and (if so) if the first argument is the symbol "interaction" (source here). If the first argument is not a list that begins with 'interaction, then the data is passed to do-value-expect and expanded more or less as is. However, if the first argument is a list that begins with 'interaction, then the data is passed to do-interaction-expect, and do-interaction-expect then destructures the data, grabbing only the pieces of the list that it cares about (source here). When I wrote this code, I found it very interesting.
When I envisioned the interaction syntax, I assumed that
(interaction ...) would be a call to a macro, and I would need to need to manipulate the data passed to interaction. However, once I got into the actual implementation, I found myself using the symbol "interaction", but never actually defining a macro or even a function. That's when homoiconicity really started to become clear to me. I'd written code that I was sure would need an implementation, yet it was used exclusively as data.
If you kept digging into this example you would find that anything found within
(interaction ...) is never used as written, but is instead expanded in a way that allows expectations to rebind the specified function and use the expected arguments at verification time. As a result, you write the same code in the same way but within your test it's used exclusively as data and in your production code it's used exclusively as code. I'm a big fan of convention, and there's no better convention than 'use the exact same thing'.
I later added the ability to add interaction tests for calls to Java objects as well, which led to the following behavior for expectations.
- If your expected value is not an interaction, it will be expanded as is.
- If your expected value is an interaction with a Clojure function, it will be used as data exclusively and expanded to rebind the function, capture all calls to the function and verify that a call occurred with the arguments you specified.
- If your expected value is an interaction with a Java method, it wil be used as data exclusively and expanded to mockito setup and verification code.