Testing Emacs Packages: surprisingly non-awful
May 15, 2014
May 15, 2014
Last summer I found myself dissatisfied with the existing solutions for managing Python virtualenvs from inside Emacs, so I wrote my own package for doing so. This was partially an exercise for me to learn more about extending Emacs and partially an attempt to improve my own Python programming workflow. Since that time the old Emacs virtualenv tool has been deprecated and mine has become a reasonably popular replacement. I figured if other people are actually using it, I should probably start taking things a bit more seriously and write some tests so I don’t accidentally break everyone’s workflow with a careless update. My previous brushes with Emacs Lisp development had led me to believe the experience was going to be extremely painful but happily this turned out not to be the case.
There are a variety of fancy Elisp testing frameworks, but I ended up going with the one built in to Emacs, ert, along with Cask and ert-runner to easily run tests from the command line in a headless Emacs instance. Here I’ll describe what the experience was like and how I used some of the nicer parts of Emacs Lisp to make it more pleasant.
The first thing I wanted to do was to simply test that activating a
virtualenv correctly makes all the changes it should. The
ert-deftest
macro is used to define tests, so I wrote:
(ert-deftest venv-workon-works ()
(venv-deactivate)
(venv-workon "science")
;; we store the name correctly
(should (equal venv-current-name "science"))
;; we change the path for python mode
(should (s-contains? "science" python-shell-virtualenv-path))
;; we set PATH for shell and subprocesses
(should (s-contains? "science" (getenv "PATH")))
;; we set VIRTUAL_ENV for jedi and whoever else needs it
(should (s-contains? "science" (getenv "VIRTUAL_ENV")))
;; we add our dir to exec-path
(should (s-contains? "science" (car exec-path)))))
note the use of the should
macro to state expectations—if the
argument to any invocation of should
doesn’t evaluate to a non-nil
value without throwing an error, the test will fail.
The above test works, but only because I already have a virtualenv
called science
. It would probably fail if you tried to run it on
your machine, since you probably don’t. The next thing I needed was
some sort of test fixtures mechanism to create a temporary virtualenv
for each test and then delete it when the test was done. I was
surprised to find that ert has no such fixtures mechanism built-in,
but only until I read the manual, which suggested just writing a macro
to do setup and teardown. Oh of course; this is lisp; we can do
that! It’s easy to forget that a lot of languages need things like
test fixtures to paper over the lack of real metaprogramming
capabilities. I wrote a with-temp-env
macro to use in my tests:
(defmacro with-temp-env (name &rest forms)
`(let ((venv-location temporary-file-directory))
(venv-mkvirtualenv ,name)
,@forms
(venv-rmvirtualenv ,name)))
The macro takes the name of a temporary virtualenv to create and some
forms to execute in that virtualenv. It then let
-binds
venv-location
to be the value of temporary-file-directory
, which
is a variable that gets set on Emacs startup to be a platform specific
location in which it’s OK to put temporary files. Finally, it makes a
new temporary virtualenv of the correct name (by splicing it in with
,name
), executes the forms (by splicing them into the let
body
with ,@forms
), and destroys the virtualenv when done. Not too
complicated and it seems to work fine.
But there’s a problem here! What if an error is thrown somewhere
during the execution of forms
? The temporary virtualenv will never
be cleaned up, since venv-rmvirtualenv
is never reached. I realized
I needed the Emacs equivalent of a finally
clause in more mainstream
languages. Turns out this is not too terrible aside from a confusing
name—the unwind-protect
macro takes a form to execute and a
“cleanup” from, and guarantees that the cleanup form will be run
regardless of whether errors are thrown in the main form. I used this
to adjust my with-temp-env
macro to the following:
(defmacro with-temp-env (name &rest forms)
`(let ((venv-location temporary-file-directory))
(unwind-protect
(progn
(venv-mkvirtualenv ,name)
,@forms)
(venv-rmvirtualenv ,name))))
I did have to wrap the call to venv-mkvirtualenv
and the form
splicing in a progn
in order to use unwind-protect
, since it
requires a single form to execute as it’s first argument, but overall
not too shabby. This version is robust to errors during the execution
of the forms
. I was able to change my venv-workon-works
test to
the following:
(ert-deftest venv-workon-works ()
(with-temp-env
"emacs-venvwrapper-test"
(venv-deactivate)
(venv-workon venv-tmp-env)
;; we store the name correctly
(should (equal venv-current-name venv-tmp-env))
;; we change the path for python mode
(should (s-contains? venv-tmp-env python-shell-virtualenv-path))
;; we set PATH for shell and subprocesses
(should (s-contains? venv-tmp-env (getenv "PATH")))
;; we set VIRTUAL_ENV for jedi and whoever else needs it
(should (s-contains? venv-tmp-env (getenv "VIRTUAL_ENV")))
;; we add our dir to exec-path
(should (s-contains? venv-tmp-env (car exec-path)))))
which will work reliably on systems besides my own. I was also able to
use the with-tmp-env
macro and unwind-protect
in my other tests,
e.g.:
(ert-deftest venv-cdvirtualenv-works ()
(with-temp-env
venv-tmp-env
(let ((old-wd default-directory))
(unwind-protect
(progn
(venv-cdvirtualenv)
(should (s-contains? venv-tmp-env default-directory)))
(cd old-wd)))))
which tests that the venv-cd-virtualenv
command works correctly.
The one thing I was missing at this point was a good test runner, like
Python’s nosetests
. It’s very convenient to be able to type one
command and have your tests collected and run with nicely reported
output at the end. This is a bit more complicated in the Emacs world
due to the need to spin up a headless Emacs instance, but again was
not nearly as bad as I was expecting.
I found ert-runner, which
is a Cask extension for running ert tests.
Cask is a project management tool for Emacs packages, somewhat like
Bundler in the Ruby world or Leiningen for Clojure. I was somewhat
miffed to have to get this set up, but it wasn’t that bad, just a
matter of adding a short Cask
file to the project’s root directory,
and, as a bonus you can now install all the dependancies for
development with cask install --dev
. ert-runner
will automatically
detect all ert tests under the test
directory of a project root and
run them in a clean Emacs instance. So now I can type:
$ cask exec ert-runner
and get some relatively nice test output:
Loading /Users/james/projects/virtualenvwrapper.el/virtualenvwrapper.el (source)...
Running 8 tests (2014-05-15 13:16:57-0500)
Deleted virtualenv: emacs-venvwrapper-test
passed 1/8 venv-cdvirtualenv-works
Deleted virtualenv: copy-of-tmp-env
Deleted virtualenv: emacs-venvwrapper-test
passed 2/8 venv-cpvirtualenv-works
Deleted virtualenv: emacs-venvwrapper-test
passed 3/8 venv-deactivate-works
Deleted virtualenv: emacs-venvwrapper-test
passed 4/8 venv-list-virtualenvs-works
Deleted virtualenv: emacs-venvwrapper-test
passed 5/8 venv-mkvirtualenv-works
Deleted virtualenv: emacs-venvwrapper-test
passed 6/8 venv-rmvirtualenv-works
passed 7/8 venv-workon-errors-for-nonexistance
Deleted virtualenv: emacs-venvwrapper-test
passed 8/8 venv-workon-works
Ran 8 tests, 8 results as expected (2014-05-15 13:17:06-0500)
Cool! The moral of the story is, don’t be afraid of testing your Emacs Lisp code, it isn’t that bad!