Emacs, ERT & Structuring Unit Tests
Latest update:
ERT framework, that everyone uses this days, provides very little
guidance on how to organize & structure unit tests.
Running tests in an Emacs session you are working in is quite
idiotic. Not only you can easily pollute the editor's global namespace
in case of mistyping, but unit tests in such a mode cannot be reliable
at all, because it's possible to create unwanted dependencies on data
structures that weren't properly destroyed in the previous tests
invocations.
Emacs batch mode
The only one right way to execute tests is to use emacs batch
mode. The idea is: your Makefile contains test
target which goes to
test
directory, which contains several test_*.el
files. Each
test_*.el
file can be run independently & has a test selector (a
regexp) that you may optionally provide as a command line parameter.
For example, consider Foobar project:
foobar/
|__ ..
|__ test/
| |__ ..
| |__ test_bar.el
| |__ test_foo.el
| |__ test_utils.el
|__ Makefile
|__ foo-bar.el
|__ foo-foo.el
|__ foo-foobar.el
|__ foo-utils.el
To make this work, each test_*
file must know where to find
foo-*.el
libraries & how to run its tests. Ideally it should not
depend on a directory from which a user actually runs it.
test_utils.el script then looks like:
:; exec emacs -Q --script "$0" -- "$@"
(setq tdd-lib-dir (concat (file-name-directory load-file-name) "/.."))
(push tdd-lib-dir load-path)
(push (file-name-directory load-file-name) load-path)
(setq argv (cdr argv))
(require 'foo-utils)
(ert-deftest ignorance-is-strength()
(should (equal (foo-utils-agenda) "war is peace")))
(ert-run-tests-batch-and-exit (car argv))
That's quite a header before the ert-deftest
definition.
1st line is a way to tell your kernel & bash to run emacs with the current file as an argument. -Q option forces Emacs not to read your ~/.emacs file, not to process X resource, etc. This helps (a) to start Emacs as quickly as possible, & (b) forces your code not to depend on your local customizations.
Next 3 lines modify load-path
list which is used by Emacs to search for files when you 'require' or 'load' something. We add to that list a parent directory, where our *.el
files are. Note that load-file-name
contains an absolute path to the current test_utils.el file.
Next line removes '--' cell from argv
list, so that (car argv)
will give you 1st command line parameter passed to the script.
(require 'foo-utils)
line loads ../foo-utils.el file (if you have provided 'foo-utils in it, of course).
Next 2 lines are the usual ERT test definition with 1 assertion in this example.
The last line is an ERT command that runs your unit tests. Notice its argument--it allows you to optionally run the script as:
$ ./test_utils.el regexp
to filter out unmatched ert-deftest definitions.
Makefile
You can add to it 2 useful targets: test & compile. The last one transforms .el files to .elc & sometimes produces useful info about unused variables, etc:
.PHONY: test compile clean
ELC := $(patsubst %.el,%.elc,$(wildcard *.el))
%.elc: %.el
emacs -Q -batch -L `pwd` -f batch-byte-compile $<
test:
@for idx in test/test_*; do \
printf '* %s\n' $$idx ; \
./$$idx ; \
[ $$? -ne 0 ] && exit 1 ; \
done; :
compile: $(ELC)
clean:
rm $(ELC)
Hints
Try to make every test non-interactive. For example, if your command
ask user for a confirmation via (y-or-n-p)
, Emacs even in the batch
mode stops and waits for an input from the terminal. If you need to
answer "yes", just monkey patch the function:
(setq tdd-y-or-n nil) ;; by default say "no"
(defun y-or-n-p (prompt)
tdd-y-or-n)
and then write an assert as:
(let ((tdd-y-or-n t))
(should (freedom-is-slavery)))
You can monkey patch any elisp function except those which are
compiled in (e.g. come from .c files & are 'primitive' in the Emacs
terminology).
Unfortunately, the famous (message)
function is built-in & cannot be
monkey patched. If you use it heavily in your code, non-interactive
tests will fill the stderr with garbage. It's better to use a global
(to your project namespace) flag & a wrapper for (message)
:
(defconst foo-meta-name "foobar")
(defvar foo-verbose 1)
(defun foo-warn (level str &rest args)
"Print a message via (message) according to LEVEL."
(when (<= level foo-verbose)
(if (/= 0 level) (setq str (concat foo-meta-name ": " str)))
(message (apply 'format str args))
))
Then use (foo-warn 1 "hi, mom")
in the code instead of
(message)
. In .el libraries foo-verbose
variable can be equal to
1, but in your tests set it to -1 to prevent printing to the stderr.
Tags: ойті
Authors: ag