November 13, 2013

Running tests in your editor (Emacs/Vim)

Recently I put together a document for the work wiki detailing how to run your tests from both Emacs and Vim. I’m publishing it here in case it’s useful for people other than just me.

If you’d like my complete Emacs and Vim configs they can be found on Github.

Running specs within Editors

Emacs

There are a couple of alternatives for running your specs within emacs. The first is rspec-mode. This is an Emacs major-mode tailored for running, the whole suite, individual spec files or individual assertions. You can install it from Elpa using M-x package-install and installing the rspec-mode package.

rspec-mode is quite a large and complex package however and my preferred approach is much simpler. It utilises Emacs compilation-mode to provide your spec output in a seperate buffer, complete with hyperlinks back into your specs (on the event of a failure).

Copy the following functions somewhere into your ~/.emacs.d/init.el

;; Find root directory by searching for Gemfile
(defun* get-closest-gemfile-root (&optional (file "Gemfile"))
  (let ((root (expand-file-name "/")))
    (loop
     for d = default-directory then (expand-file-name ".." d)
     if (file-exists-p (expand-file-name file d))
     return d
     if (equal d root)
     return nil)))

(defun find-spec-from-file ()
    (interactive)
    (save-excursion
        (find-file
        (replace-regexp-in-string
            "\n$" ""
            (shell-command-to-string
            (concat "find "
                (get-closest-gemfile-root)
                   " -type f -name "
                   (concat (substring (car (last (split-string (buffer-file-name) "/"))) 0 -3)
                           "_spec.rb")))))))

    (defun rspec-format-string (&optional line-p)
      (concat "cd %s && "
              (when (file-directory-p "~/.rvm") "source ~/.rvm/scripts/rvm && rvm use &&")
              "bundle exec rspec --format documentation %s"
              (when line-p " -l %s")))

    (defun rspec-compile-file ()
      (interactive)
      (compile (format (rspec-format-string)
                       (get-closest-gemfile-root)
                       (file-relative-name (buffer-file-name) (get-closest-gemfile-root))
                       ) t))

    (defun rspec-compile-on-line ()
      (interactive)
      (compile (format (rspec-format-string t)
                       (get-closest-gemfile-root)
                       (file-relative-name (buffer-file-name) (get-closest-gemfile-root))
                       (line-number-at-pos)
                       ) t))

The functions are as follows.

  • find-spec-from-file: When run in a Ruby code file, this will attempt to open the corresponding spec file in a new buffer. This is pretty naive but seems to work for most models, controllers and lib files in a rails app as long as the standard rspec-rails conventions are followed.

  • rspec-format-string: This is an internal helper function and not designed to be used directly (hence the lack of an interactive declaration). It is useful to help customize how your spec output appears. In this example we use the --documentation format.

  • rspec-compile-file: Run all the specs in the current buffer. Show the output in a new compilation-mode buffer.

  • rspec-compile-on-line: Same as above but only run’s the current spec underneath the point. This is useful when the cursor is positioned anywhere within an it block.

Because these commands shell out to the rspec executable they work equally well with regular test and with rspec feature style tests.

Keybindings

You should bind some of these functions to convenient shortcut keys to avoid running them with M-x all the time. Mine look like this

(local-set-key (kbd "C-c l") 'rspec-compile-on-line)
(local-set-key (kbd "C-c t") 'rspec-compile-file)

Caveats

You will need to make sure that Emacs knows about the correct version of Ruby for your system. How you do this will depend entirely on how you have installed Ruby. Emacs modes exist for rvm, rbenv and chruby.

Setting up chruby-mode is relatively straightforward, although it isn’t available in the Emacs package repositories yet. You’ll need to clone the repository

git clone https://github.com/plexus/chruby.el.git

Make sure you’ve put the repo somewhere in your emacs load-path and then do the following in your init.el

(require 'chruby)
(chruby "2.0.0-p247")

Other Ruby version manager instructions will vary but should look relatively similar.

Vim

Again, there are a couple of different approaches for running your specs from within Vim. The first, quickest, but slightly more manual is just to dynamically bind a shortcut key to run the current buffer through rspec. You can do this as follows:

map ;t :w\!bundle exec rspec %<cr>

When you press the key sequence ;t Vim will save the current file and run the shell command

bundle exec rspec path/to/current/buffer.rb

Output will be shown synchronously in the current terminal/gvim session.

A more reliable and robust way to achieve this is to add some functions and some keybindings to your .vimrc.

" Switch between test and production code
function! OpenTestAlternate()
  let new_file = AlternateForCurrentFile()
  exec ':e ' . new_file
endfunction

function! OpenTestAlternateVsplit()
  let new_file = AlternateForCurrentFile()
  exec ':vs ' . new_file
endfunction

function! AlternateForCurrentFile()
  let current_file = expand("%")
  let new_file = current_file
  let in_spec = match(current_file, '^spec/') != -1
  let going_to_spec = !in_spec
  let in_app = match(current_file, '\<controllers\>') != -1 || 
        \      match(current_file, '\<presenters\>') != -1 || 
        \      match(current_file, '\<decorators\>') != -1 ||
        \      match(current_file, '\<models\>') != -1 || 
        \      match(current_file, '\<helpers\>') != -1 || 
        \      match(current_file, '\<views\>') != -1
  if going_to_spec
    if in_app
      let new_file = substitute(new_file, '^app/', '', '')
    end
    let new_file = substitute(new_file, '\.rb$', '_spec.rb', '')
    let new_file = 'spec/' . new_file
  else
    let new_file = substitute(new_file, '_spec\.rb$', '.rb', '')
    let new_file = substitute(new_file, '^spec/', '', '')
    if in_app
      let new_file = 'app/' . new_file
    end
  endif
  return new_file
endfunction

" Running tests
function! RunTests(filename)
    " Write the file and run tests for the given filename
    :w
    :silent !echo;echo;echo;echo;echo;echo;echo;echo;echo;echo
    let l:params = '-f documentation'
    if a:filename == ''
      let l:params = ''
    end

    if match(a:filename, '\.feature$') != -1
        exec ":!bundle exec cucumber --require features " . a:filename
    else
        if filereadable("script/test")
            exec ":!script/test " . a:filename
        elseif filereadable("Gemfile")
            exec ":!bundle exec rspec ". l:params . " " . a:filename
        else
            exec ":!rspec " . l:params . " " . a:filename
        end
    end
endfunction

function! SetTestFile()
    " Set the spec file that tests will be run for.
    let t:grb_test_file=@%
endfunction

function! RunTestFile(...)
    if a:0
        let command_suffix = a:1
    else
        let command_suffix = ""
    endif

    " Run the tests for the previously-marked file.
    let in_test_file = match(expand("%"), '\(.feature\|_spec.rb\)$') != -1
    if in_test_file
        call SetTestFile()
    elseif !exists("t:grb_test_file")
        return
    end
    call RunTests(t:grb_test_file . command_suffix)
endfunction

function! RunNearestTest()
    let spec_line_number = line('.')
    call RunTestFile(":" . spec_line_number)
endfunction

This is roughly equivalent to the emacs commands above - there are functions for switching to the alternate file (in the same window or a vertical split, to have code and specs side by side), running all the tests in the suite, a single file or a single line. You can map these functions to keys as follows:

nnoremap // :call OpenTestAlternateVsplit()<cr>
nnoremap /. :call OpenTestAlternate()<cr>
nnoremap ;t :call RunTestFile()<cr>
nnoremap ;T :call RunNearestTest()<cr>
nnoremap ;a :call RunTests('')<cr>

Caveats

As above - this requires that vim knows about your current Ruby interpreter. This can be acheived by forcing vim to use the same settings for $PATH as your shell (assuming you’ve run the correct ruby version manager setup in your shell). This is acheived by putting the following lines at the top of your .vimrc

" Get a good value for $PATH.
let $PATH = system("printenv PATH")
let $PATH = substitute($PATH, "\<C-J>$", "", "")

Also, the RunNearestTest command doesn’t work correctly in rspec feature files as yet. It appears to filter out all the examples. I need to work out why this is.

Bonus - Asynchronous tests in Vim

Emacs runs tests asynchronously by default. You can still edit files while the compilation process is happening in another buffer whereas Vim blocks the editor while running the tests. I often find this approach favourable when test driving a design, however with slow tests this can become a frustrating impediment. You can work around this by utilising a named pipe in your shell.

This requires a small bash script to be added somewhere in your $PATH to manage the named pipe and a specific key mapping in Vim to send the correct rspec command into the named pipe.

Copy this code into a file in your path and make it executable with chmod +x. Mine is called accept-tests.sh and lives in ~/bin

#!/bin/bash
# accept-tests.sh

function cleanup(){
  echo "cleaning up fifo..."
  rm test_pipe
  exit $?
}

trap cleanup SIGINT

if [[ ! -p test_pipe ]]; then
  mkfifo test_pipe
fi

while true; do
  sh -c "$(cat test_pipe)" 
done

Now add the mapping into your .vimrc to send the output to the named pipe:

nnoremap ;f :w\|:!echo "clear && bundle exec rspec -f documentation %" > test_pipe<cr>\|:redraw!<cr>

When you want to run your tests: open a terminal, navigate to the root of your project directory and run accecpt-tests.sh; this will block the terminal window on the named pipe and will remain open, waiting for input.

Make sure you start your vim instance from the same directory you created the named pipe in, and now when you run your tests with ;f you should see the output in the original terminal window and your editor will no longer block.