To avoid unnecessary explanations, let us imagine that it was the factorial program.
(defn factorial [n]
(if (= n 0) 1 (* n (factorial (dec n)))))
(factorial 1)
(factorial 10)
(map factorial (range 10))
Eventually I find that, while I can't be bothered going back and doing this too often, I'd like my little set of regression tests to be run every time I recompile the file. This catches many mysterious bugs, and is generally worth its weight in gold. I find if this is going on, then I don't miss the security of static type checking.
It's also nice to have the original tests still in the file, so that I can execute them explicitly if I want to, and they also serve as handy documentation for the function.
What I end up writing is:
(and
(= (factorial 1) 1)
(= (factorial 10) 3628800)
(= (map factorial (range 10)) '(1 1 2 6 24 120 720 5040 40320 362880)))
Here is a single expression which gets executed every time the file is loaded, which runs my factorial function in a number of cases.
That's actually really handy in itself, and I can execute it manually, and I can execute the sub-expressions.
But it silently throws the result away if the test fails on load, so although it will catch type errors, it doesn't catch bugs.
So I change it to be:
(let [test
(and
(= (factorial 1) 1)
(= (factorial 10) 3628800)
(= (map factorial (range 10)) '(1 1 2 6 24 120 720 5040 40320 362880)))]
(println "factorial" "tests" (if test "pass" "fail"))
test)
But at this point I'm already annoyed that after every function that I write, I have to repeat a block of code like this, and I find myself using cut and paste a lot, which is never a good sign, and if I want to improve the test code I find myself making the same changes in various different places, and in short my senses are screaming for an abstraction of some sort.
In fact for this example a functional approach would work, but I know from doing this many times before in various languages that it always ends up being difficult when you come to reporting errors, and so you end up, if you can, doing it as a macro eventually, so I'll make it a macro from the start.
Now, what I want is to be able to write
(testset "factorial"
(= (factorial 1) 1)
(= (factorial 10) 3628800)
(= (map factorial (range 10)) '(1 1 2 6 24 120 720 5040 40320 362880)
)
(let [test
(and
(= (factorial 1) 1)
(= (factorial 10) 3628800)
(= (map factorial (range 10)) '(1 1 2 6 24 120 720 5040 40320 362880)))]
(println "factorial" "tests" (if test "pass" "fail"))
test)
(testset title test1 test2 ...) ->
(let [test (and
test1
test2
...
)
]
(println title "tests" (if test "pass" "fail"))
test)
(defmacro testset [title & tests]
`(let [test# (and
~@tests)
]
(println ~title "tests" (if test# "pass" "fail"))
test#))
and ~ (unquote) to put the title where it should be
and ~@ (splicing unquote) to put the variable argument list of tests into the code as if they'd been typed there one by one, and not as a list
and we've got a local variable test which we want to get generated as an opaque symbol so that it won't interfere with any other variables, so we use clojure's auto-gensym facility to do this automatically, just by calling it test# .
We can use macroexpand to see what's going on
(macroexpand '(testset "hi" (= (println "Hello World") nil)))
(let* [test__6260__auto__ (clojure.core/and
(= (println "Hello World") nil)
)]
(clojure.core/println "hi" "tests" (if test__6260__auto__ "pass" "fail"))
test__6260__auto__)
And I have to confess that I am not sure what is going on here, but it seems to work!
It worries me that I don't understand the macro mechanism here.
In classic lisp, macros are very simple and powerful things, but there are a couple of traps into which it is easy to fall for beginners. But they are not too difficult to avoid if you understand how things work
In classic scheme, there is a hygenic macro mechanism, which is hard to understand but results in the traps not being there. But it also results in some of the power being difficult to get at. Once you actually grok it, however, it's a dream to use.
Clojure appears to have taken a middle path which I do not yet understand. Macros seem easy to write and the usual traps seem covered.
I worry that there may be different traps for the unwary.
But it is so useful that I am doomed to find them, if they are there....
And this is a good thing!
Hi John,
ReplyDeleteGreat minds (you and Stuart Sierra) evidently think alike - I suspect you've reinvented the test-is lib. It's in clojure-contrib.test-is in version 1.0, or in clojure.test in version 1.1
jim
It's quite hard to avoid inventing something like this if you put unit tests in lisp code! The redundancy leaps out, and it's very easy to make it go away.
ReplyDeleteThey tend to get quite complicated eventually, (I'll show you the versions for my various toy scheme interpreters and compilers one day...), just because you can bury the complexity in macros so it doesn't spoil the main code. In fact it acts as documentation.
I thought that this particularly brain-dead version would make a nice introductory macro, since despite being very simple in concept, it uses `, ~, ~@, and #, which are the four main bits of macro-syntax in clojure.
Thanks for the tip about the library. I'll have a look.
What would be really wonderful would be some way to syntax highlight in emacs according to whether your tests had run the code or not.
In PLT's wonderful DrScheme environment you can make tested code go green and untested code go red. It's brilliant for catching little conditions that you've forgotten, and if you can get the whole program to go green then you actually tend to feel a lot more confident in the robustness of your program than you do in a statically type checked language. Not only little chance of type errors, but some evidence that your functions will do what you want as well!