“Regression testing”? What’s that? If it compiles, it is good; if it boots up, it is perfect.
– Linus Torvalds, linux-kernel mailing list, April 1998
System under test
First, let’s get this out of the way: this is not an article about testing. I’m not going to tell you why you should or should not test things (hint: you should) or how you should do it (hint: integration testing is always better than unit testing), what you should test (hint: everything) or what colour socks you should wear when you install the JavaScript testing framework that was just released on the latest social network (hint: purple always purple).
No, this article is about Vim and Vim scripting. Arguably, it’s about advanced Vim scripting, but ultimately it’s about Vim.
We’re going to talk about how you can use Vim to test your Vim plugins without using other Vim plugins. In particular, we’re going to focus on one of the things that’s difficult to test: insert-mode completion. I’ve picked this for a few reasons:
- It’s something that, as far as I know, no existing Vim testing “framework” makes easy.
- As the maintainer of a popular autocompletion plugin, this is an area with which I have some experience.
- It’s interesting. At least, it’s interesting to people like me. Make of that what you will.
While I will talk about the vim assert_*()
functions and
other built-in testing primitives, I’m not going to repeat what you can read in
:help testing.txt
.
Hopefully the details (or if I may be so bold, lessons) presented here will provide some insight into somewhat advanced Vim usage and scripting.
Should you read this
Probably not. But if you’ve got this far, you’re probably one of the following:
- An existing Vim aficionado, looking to pick holes in my article (thanks!)
- A plugin author looking to find out how to use core Vim functions to test your own plugin(s).
- An intermediate or advanced Vim user who is curious about different Vim script use cases
- A beginner Vim user who would like to take a look at “how the sausages are made”
You’re probably not:
- A regular Vim user who doesn’t care too much about fiddly details
- Looking for a new cool plugin for a new cool JavaScript framework
- Looking for some new mappings to paste in your
vimrc
Apparatus
So, to get started, let’s review what we need to start testing our plugin. A lot
of you will probably google vim testing framework
at this point, and you might
find something that works. I didn’t, so I asked myself one question “What does
Vim use to test Vim?“.
And the answer, simply, is Vim.
In order to test our Vim plugin (and indeed, Vim itself), we need the following:
- Vim
- Something to test
I’m assuming you have the former, and I’ve put together a very simple
completion plugin which we’ll be testing. The test plugin just
implements the ‘months completion’ example from :help
complete-functions
in 2 ways:
- Synchronously:
attest#CompleteSimple
performs a simple insert-mode completion and returns the results - Asynchronously:
attest#CompleteAsync
does the same, but after a delay (using a timer)
If you’d like to follow along and play with the examples, you can check out the example code in any directory you like:
$ cd /some/path
$ git clone https://github.com/puremourning/a-test-to-attest-to.git
Environment
I’m sorry but all the examples here assume you’re running on Unix-like OS (Linux, macOS, etc.). While everything here applies equally to Windows, and any other OS that Vim runs on, the examples of running tests will assume a Bourne-like shell. You can adapt this to your operating system of choice at your own leisure.
Testing our plugin manually
First things first, we need to work out what we need to do to test this plugin. Naively, we might just:
- Sync the plugin to our
pack/attest/start/attest
directory - Run
vim test_file
- Set the
completefunc
for user-defined completion - Type something, and trigger completion with
<C-x><C-u>
- Check that the correct months are returned
- Repeat for both
completefunc=attest#CompleteSimple
andattest#CompleteAsync
.
Isolation
But there’s a problem: our test is not isolated. It’s actually being affected
by a number of things, but most importantly any existing vimrc
or other Vim
configuration in your user account, system, distribution, etc.
While I promised to not preach about how to test, this point is important: tests should be isolated, idempotent and minimal. They should not rely on any external environmental configuration (unless that’s part of the test suite) and should leave the system clean after they have run.
So how do we isolate our test? Well, fortunately Vim has a command line option
to start in a clean
way. By clean, we mean with it’s default configuration:
$ vim --clean
We’ll be using this, but for the record, there’s and even cleaner way to start Vim, with no configuration or initialisation scripts:
$ vim -Nu NONE
So, there we have it:
- Sync the plugin to our
pack/attest/start/attest
directory - Run
vim --clean test_file
- Set the
completefunc
for user-defined completion - Type something, and trigger completion with
<C-x><C-u>
- Check that the correct months are returned
- Repeat for both
completefunc=attest#CompleteSimple
andattest#CompleteAsync
.
Repeatability
There’s another problem. You probably worked this one out. The test is too manual. Even if we ignore the manual entering, triggering and checking, we’re doing a bunch of setup code manually. We should really have the setup code done automatically, so that our isolation can be used to ensure consistency and simplicity of our test runs.
A nice way to do this is to provide a vimrc
-like script to set up the test
environment. Recall that we need to set:
- The package path (or runtime path)
- The
completefunc
, which needs to differ according to which mode we’re testing
A very simple way to do this is to create 2 simple files:
Here’s the meat of the simple one (the async one is almost identical):
let &rtp .= ',' .expand( "<sfile>:p:h:h:h" )
set completefunc=atest#CompleteSimple
This adds the root path of the plugin (the parent of the parent of the parent of
the path to the script!) to the runtimepath
, then sets completefunc
to our simple function.
So how to use this? Well, enter more Vim command line options. The -S
option tells Vim to source the argument after all initialisation has
completed. Neat.
This small change allows us to drastically simplify our test instructions.
- Sync the plugin to any directory you like
- Run
vim --clean -S support/test/test_simple.vim test_file
- Type something, and trigger completion with
<C-x><C-u>
- Check that the correct months are returned
- Repeat for both
test/support/test_simple.vim
andtest/support/test_async.vim
.
But we’re not done. Not by a long way.
Formalisation
Now that we’ve got the boilerplate setup out of the way, let’s have a think about how our actual test can be automated. This is, of course, the meat and vegetables of testing in practice; “frameworks” such as the above initialisation scripts are written once and only changed when needed, whereas most time is spent writing actual tests that actually validate quality and correctness.
So what did we do manually? Well we just sort of typed some stuff and checked it was right. We can’t automate that (yet?), so we need to formalise our test cases.
But first, let’s take a look at what the plugin does so our test cases make sense. For those not familiar, insert mode completion is done in two stages:
- FindStart: Vim asks you to find the start column for the completion.
Usually this is the start of the current word, but might be anything
depending on the file type. (examples: after
attest#
for Vim, afterastruct.
for C, etc.) - Complete: Then, it calls you again passing the ‘query’ (the bit between
the start column and the cursor) and asking you to return the matching
completion items (examples:
Compl
inattest#Compl
for Vim,memb
inastruct.memb
for C, etc.)
The entire test plugin completion code, split into the 2 stages, is as follows:
function! s:FindStart() abort
" locate the start of the word (stage 1)
let line = getline('.')
let start = col('.') - 1
while start > 0 && line[start - 1] =~ '\a'
let start -= 1
endwhile
return start
endfunction
function! s:CompleteMonth( base ) abort
" find months matching with "a:base" (stage 2)
let res = []
for m in split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec")
if m =~ '^' . a:base
call add(res, m)
endif
endfor
return res
endfunction
In stage 1, we just look backwards from the cursor for the first
non-alphabetical character and return that as our start column. In stage 2 we
attempt to match the ‘query’ (a:base
) against the months of the Gregorian
calendar. This example code is from Vim help; don’t shoot the messenger.
We’ll talk about the asynchronous completion later, but for the synchronous
version, the actual completefunc
is trivial:
" See :help complete-functions
function! attest#CompleteSimple( findstart, base ) abort
if a:findstart
return s:FindStart()
else
return s:CompleteMonth( a:base )
endif
endfunction
So we can formulate some simple test cases:
- When the word before the cursor is empty, all months are presented in the
popup menu, and
Jan
is inserted (the first month). - When the word before the cursor is
F
,Feb
is inserted and there is no popup menu. - When the word before the cursor is
M
, monthsMar
andMay
are presented, andMar
is inserted.
Therefore we can formalise our test “script” as follows:
- Sync the plugin to any directory you like
- Run
vim --clean -S support/test/test_simple.vim
- Type
i<C-x><C-u>
. Expect the buffer to containJan
and a popup menu with the following contents:- “Jan”
- “Feb”
- “Mar”
- … etc. you get the point
- Type
<Esc>:%bwipe!<CR>
to clear out the buffer - Type
iF<C-x><C-u>
. Expect the buffer to containFeb
- Type
<Esc>:%bwipe!<CR>
to clear out the buffer - Type
iM<C-x><C-u>
. Expect the buffer to containMar
and a popup menu with the following contents:- “Mar”
- “May”
- Repeat for both
test/support/test_simple.vim
andtest/support/test_async.vim
.
Of course there are tons more cases to test, but this should do for now.
At this point, if you’re following along at home, satisfy yourself that the completion plugin works by following those test instructions. Next we’ll need to automate them, so it makes sense to be familiar with what will happen.
Automation
Finally, this is the part where we answer the question “How can I test my Vim plugin with pure Vim?“. Yes, the question you were told you asked at the beginning of the article.
Of course this is not a straightforward answer. Let’s take it bit-by bit.
Automating typing with feedkeys
It may not be obvious, but the best way to test the user typing some commands is to actually tell Vim to pretend that’s exacty what happened.
There are ways to actually enter text by running Vim in another terminal and
sending keypresses to that terminal, but we’re not going to cover that here.
We’re going to be using feedkeys()
.
Taking a look at the help for feedkeys()
can be a little overwhelming, but
fear not, I’ve read it enough times to have a vague understanding of it. I’ve
also stepped through the Vim code with Vimspector
to understand exactly what’s going on.
How Vim works
As a terminal application, Vim is essentially a main loop a bit like this pseudocode:
extern char *input_buffer;
int input_len = 0;
while (!finished) {
input_buffer[ input_len++ ] = read_character_from_standard_input();
expand_mappings_in_buffer( &input_buffer, &input_len )
if ( have_command_in_buffer( input_buffer ) ) {
handle_command_in_buffer( &input_buffer, &input_len );
}
}
That is, read characters into a buffer until you know what to do, then do it. (Disclaimer: It’s a tad more complex than that, but suffice to say there is a buffer of characters to be processed, which can contain commands made up of sequences of characters and that some sequences of characters can be expanded into some other sequence of characters).
How feedkeys works
Intuitively, feedkeys()
pushes characters into the input buffer. This
effectively tells Vim to execute as if the pushed characters were entered. But
there are a few things you need to know:
- To enter special characters, like
<C-x>
, you must use double quotes"
and a backslash\
before the<
, as infeedkeys( "\<C-x>" )
- By default,
feedkeys()
treats characters like the rhs of a mapping - By default,
feedkeys()
just puts things in the queue, but doesn’t actually execute them - By default, if you wait for keys to be executed,
feedkeys()
ends insert mode (if it is active) when it returns, as if the command ended with<Esc>
. - By default,
feedkeys()
expands mappings in the input (like:map
rather than:noremap
)
How feedkeys can work for us
Less intuitively perhaps, characters pushed by feedkeys()
are treated by
default as if they come from a mapping, not as if they were entered by the
user. What does this mean? Well if you’ve ever written a Vim mapping it might
make sense to you. A mapping is a sequence of characters in the input buffer
(the lhs) which is replaced by the characters on the other side (the rhs)
of the mapping. What the user typed was the lhs of the mapping, but what
actually executes is the rhs. Importantly, things like undo, folding,
etc. are treated according to the lhs not the rhs.
We’re interested in testing what the user types, so we want to change that.
That’s easy; we pass the t
flag in the second argument to feedkeys()
. This
leads to the First Rule of Testing With feedkeys
:
When testing, we (almost) always pass the
t
flag tofeedkeys()
to make it as if the user entered the keys themselves.
More surprisingly, and less usefully for testing, feedkeys()
returns after
having put the characters in the buffer, but before the input buffer has
actually been read and executed. This means that if you use it for testing, your
test will finish, but it won’t have actually done anything yet. Doh! Again,
there’s a simple Rule:
When testing, we (almost) always pass the
x
flag tofeedkeys()
to make Vim keep executing until the input buffer is empty.
We’ll look at this more later, but it’s also important to realise that if
feedkeys( '...', 'x' )
would leave Vim in insert mode, it acts as if the
command were feedkeys( "...\<Esc>", 'x' )
. Again this is easily avoided, but
has some serious implications for the test:
When testing insert-mode functionality, we sometimes need to tell Vim to stay in insert mode after
feedkeys
. We pass the!
flag tofeedkeys
for this, and we always usefeedkeys( "\<Esc>" )
in another callback to ensure we actually quit insert mode.
And finally, as we usually want to test what happens when the user typed something we almost always want mappings in the input string to be expanded. This is the default, but it pays to reiterate:
When testing user typed commands, we never pass the
n
flag tofeedkeys()
so that mappings are applied to the input buffer.
Phew. That was a lot of detail about feedkeys
. But trust me, this is the
number 1 thing to understand. If you take nothing else from this article, this
is the section to try and retain; if you can successfully wield feedkeys()
,
you can write tests that behave the way your users will actually experience.
Let’s put this to use, going all the way back to our very first test case. For now, while we learn the ropes, let’s ignore testing the popup menu, and just check the buffer contents. This actually lets us test a large amount of our plugin functionality.
So, our test case is:
- Type
i<C-x><C-u><Esc>
. Expect the buffer to containJan
.
We need to translate that to some vimscript. Based on what we know now about
feedkeys()
, it’s a piece of cake:
call feedkeys( "i\<C-x>\<C-u>", 'xt' )
Due to the x
flag, this call will:
- Enter insert mode:
i
- Trigger user-defined completion:
<C-x><C-u>
- Exit insert-mode (implicitly due to use of
x
flag without!
flag)
Tada! You just automated typing. Now, let’s check if it did what we expect.
Validating results
After all that detail, the following sections are going to feel very light and breezy. That’s good; it means you’ve pretty much done all the mental heavy lifting required in this article and you’re now on the downhill section towards a well-deserved cup of java.
Recall that we’re trying to implement the following minimal test case:
- Type
i<C-x><C-u><Esc>
. Expect the buffer to containJan
.
We’ve covered the first part, and we now need to deal with the second part. This involves:
- Getting the current buffer contents (line contents, whatever)
- Asserting that they match what we expect
The first is super easy: getline( 1 )
does the trick. There are
tons of other ways to inspect buffer contents in vimscript, and I won’t list
them. The more interesting part for the purposes of this article are how to
assert matching values.
By now you’ve probably worked out that there’s
assert_equal()
which is what we want:
call feedkeys( "i\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Jan', getline( 1 ) )
But that’s not all we need. Let’s take a minute to talk about how the assert_
functions work in Vim and what that means for testing.
Assertions and v:errors
The way assertions work in Vim is a little unusual and might not be immediately
intuitive to people familiar with other testing tools. First and foremost, the
assert_*()
functions do NOT throw an error if the assertion fails. That
means that even if an assertion fails, your script will continue executing.
So what do the assert_*
functions do when they fail? Well, they return 0
and
add reports to the special v:errors
list.
That is:
A test is considered to have failed if
v:errors
is a non-empty list.
Let’s explore the other sources of “failure” too.
Exceptions and tracebacks
Errors and exceptions do occur, and they can be trapped with :try
etc. For
reasons that will become clearer when we wrap this all up into a neat little
“framework”, it is not considered good practice to “fail” a test by throwing or
triggering an uncaught error/exception. It’s best to use the assert functions,
or add things to v:errors
with call add( v:errors, ... )
.
However, an exception might be thrown due to a bug in our code! We should catch these uncaught exceptions and mark our test as failed if there are any:
A test is considered to have failed if an exception is thrown and not caught within the test.
try
" do the test
catch
call assert_report( "Uncaught exception in test: "
\ . v:exception
\ . " at "
\ . v:throwpoint )
endtry
Early exit
While it probably goes without saying, it’s not a good idea for the test to
cause Vim to exit. So, we want to trap that and report it as a
failure. For that we can use VimLeavePre
autocommand:
au VimLeavePre * call s:EarlyExit()
try
" do the test
catch
" Handle uncaught
finally
au! VimLeavePre
endtry
The s:EarlyExit()
function will be defined later, but you can guess that it
adds something to v:errors
and then quits with an error code.
Reporting results
After running your test, you need to know if it was successful, and if not, why
not. The former is fairly easy: we can make Vim exit with a nonzero exit code on
failure. For this, there is :quit!
for success and
:cquit!
for failure:
if len( v:errors ) > 0
cquit!
else
quit!
endif
Unfortunately, getting the reason for failure is not so straightforward. Due
to Vim being a terminal-mode application itself, the obvious choice
(print something to stdout/stderr) isn’t really available. :echom
and suchlike
will all be lost when Vim exits. So what we normally do is to write all the
messages reported to a file (called <file>.failed.log
, why not) and have a
wrapper script detect the failure and print them out.
" Append errors to test failure log
let logfile = expand( "<sfile>:p:t" ) . ".failed.log"
call writefile( v:errors, logfile, 'as' )
Putting it all together
So, recalling that the test we want to run is:
call feedkeys( "i\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Jan', getline( 1 ) )
We can create a script to automate this for CompleteSimple, encompassing all of the above, as follows:
let init_script = expand( '<sfile>:p:h' ) . '/../support/test_simple.vim'
execute 'source ' . init_script
function! s:EarlyExit()
call add( v:errors, "Test caused Vim to quit!" )
call s:Done()
endfunction
function! s:Done()
if len( v:errors ) > 0
" Append errors to test failure log
let logfile = expand( "<sfile>:p:t" ) . ".failed.log"
call writefile( v:errors, logfile, 'as' )
" Quit with an error code
cquit!
else
quit!
endif
endfunction
" * Type `i<C-x><C-u>`. Expect the buffer to contain `Jan`
let v:errors = []
au VimLeavePre * call s:EarlyExit()
try
call feedkeys( "i\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Jan', getline( 1 ) )
catch
call add( v:errors,
\ "Uncaught exception in test: "
\ . v:exception
\ . " at "
\ . v:throwpoint )
finally
au! VimLeavePre
endtry
call s:Done()
Go on, try it! You can clone the test repo and run:
$ vim --clean -S test/scripts/test_simple.vim
$ echo $?
0
And to confirm that it works, if we change the check to getline( 2 )
, we get
this:
$ vim --clean -S test/scripts/test_simple.vim
$ echo $?
1
$ cat test/scripts/test_simple.vim.failed.log
/Users/ben/Development/vimways/a-test-to-attest-to/test/scripts/test_simple.vim line 30: Expected 'Jan' but got ''
Done! You now know everything you need to to use Vim’s built in testing functionality to test Vim with Vim.
A test “framework”
But that’s a lot of boilerplate to write for every script. The ratio of boilerplate to test lines is about 20:1, which is obviously terrible. So to make this into a workable “framework”, we pull out all of the stuff that doesn’t differ between individual tests. That is:
- General setup
- Running the test and catching exceptions, Vim exit, etc.
- Reporting results
The way we do this is to pull all of that into a single script run_test.vim
and always source that file.
But how does it find what tests there are to run ? Well, there are a few ways we could do this, including:
- Scanning a directory for files called
*.test.vim
and sourcing them in turn - Sourcing a specified file and scanning for functions named
Test_*
On reflection, we could of course do both. But we’re just going to use the
later because that’s what Vim’s tests do and what my own setup uses. It also
makes global setup and teardown functions simpler to reason about. But you’re
free to do whatever works for you (hint: checkout glob()
). By
writing this all yourself, you can make it work best for you.
Test discovery from functions
If we define each individual test case as a Vim function, then we can just source the test script and search for functions that are defined (globally) matching a particular pattern. Therefore, our approach will be:
- Source the test script containing the test cases (functions)
- Find the list of functions matching and any setup/teardown functions
- Run each test function in a “clean” environment
We put this logic into run_tests.vim
. The process to run a test is going to be
this:
$ vim --clean -S run_test.vim /path/to/the/test/script
That is, we’re going to open a test script in the editor, then source
run_test.vim
. While run_test.vim
is executing, the ‘test script’ is the
buffer identified by %
(i.e. the current buffer), so we can source the test
script containing the functions with:
" Sources /path/to/the/test/script in our example above
source %
" Unloads /path/to/the/test/script in our example above
bwipe!
We can then find all the functions we need to run by inspecting the output of
the :function
command, passing a filter argument. Sadly there’s
no vimscript function to get this, so we capture the output using
execute()
and parse the result:
" Extract the list of functions matching ^Test_
let s:tests = split( substitute( execute( 'function /^Test_' ),
\ 'function \(\k*()\)',
\ '\1',
\ 'g' ) )
In order to allow scripts to run in different configurations, we actually defer
the global setup to the scripts, by looking for SetUp
and TearDown
functions. This leads to the following “main” test loop:
if exists("*SetUp")
call SetUp()
endif
" ... run all of the Test_* functions
for test_function in s:tests
%bwipe!
let v:errors = []
au VimLeavePre * call s:EarlyExit()
try
execute 'call ' . test_function
catch
call add( v:errors,
\ "Uncaught exception in test: "
\ . v:exception
\ . " at "
\ . v:throwpoint )
finally
au! VimLeavePre
endtry
endfor
if exists( "*TearDown" )
call TearDown()
endif
Then we can write our actual test script:
function SetUp()
let init_script = g:test_path . '/../support/' . g:test_name
execute 'source ' . init_script
endfunction
function Test_Simple_Empty()
call feedkeys( "i\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Jan', getline( 1 ) )
%bwipe!
endfunction
function Test_Simple_February()
call feedkeys( "iF\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Feb', getline( 1 ) )
%bwipe!
endfunction
function Test_Simple_March()
call feedkeys( "iM\<C-x>\<C-u>", 'xt' )
call assert_equal( 'Mar', getline( 1 ) )
%bwipe!
endfunction
function Test_Simple_May()
" Use C-n to prove that the second option is May
call feedkeys( "iM\<C-x>\<C-u>\<C-n>", 'xt' )
call assert_equal( 'May', getline( 1 ) )
%bwipe!
endfunction
We run this with:
$ vim --clean -S test/run_test.vim test/tests/test_simple.vim
$ echo $?
0
And that really is it, you now have a framework on which to build Vim tests using about 70 LOCs of Vimscript.
Bonus material - insert mode completion
As I mentioned earlier, I want to tackle specifically insert-mode completion testing. So far we’ve actually just touched the surface and put together a little framework for writing tests (and arguably nothing that clever); now comes the (optional) interesting part.
So let’s think about how we can check the popup menu. First, let’s explore what facilities Vim provides to even look at the current contents of the popup menu:
pumvisible()
complete_info()
pum_getpos()
(in very recent Vim builds)
Of course, all of these things are only useful during insert-mode. Recall that
by default feedkeys()
leaves insert mode on return, so in order to actually
verify the popup menu in insert mode, we have to be a little clever. There are
actually a few ways to do this, one of which involves using the “expression
register” to run a function.
For anyone not familiar, the expression register is a neat tool, which allows us to run an arbitrary vimscript expression without leaving insert mode and insert the result in the buffer. For our purposes, we don’t really want to insert the result, we just use a side-effect of running the expression to validate the current buffer contents.
Here’s an example (from test/tests/test_simple_insert_mode.vim
), which
triggers user defined completion, then immediately calls the check function via
the expression register. The function itself returns ''
so as not to affect
the buffer:
function Test_Popup_Menu_Expression_Register()
function TestPopupContents()
let items = complete_info().items
call map( items, {index, value -> value.word} )
call assert_equal( [ 'Mar', 'May' ], items )
return ''
endfunction
call feedkeys( "iM\<C-x>\<C-u>\<C-r>=TestPopupContents()", "xt" )
delfunc! TestPopupContents
%bwipe!
endfunction
This works well for this case, but won’t work for all cases. In particular, this won’t work when we try to test the async version.
Testing the async version
So far we have looked exclusively at testing the synchronous version of our completion engine. But we are interested also in testing our asynchronous version. So how do we do that? Well, we could try running our synchronous (simple) tests against the async version of our completer.
Let’s see what happens if we just take the above example, and source the async setup script…
$ vim --clean -S test/run_test.vim test/tests/test_async_using_simple_approach.vim
$ echo $?
1
Blerg. It failed? Let’s see why:
$ cat test/tests/test_async_using_simple_approach.vim.failed.log
function Test_Async_February line 2: Expected 'Feb' but got 'F'
function Test_Popup_Menu_Expression_Register[8]..TestPopupContents line 3: Expected ['Mar', 'May'] but got []
function Test_Async_Empty line 2: Expected 'Jan' but got ''
function Test_Async_May line 2: Expected 'May' but got 'M'
function Test_Async_March line 2: Expected 'Mar' but got 'M'
Oh, that’s sad… it looks like our async completion plugin doesn’t work! But wait! When we tested it manually, it worked fine. So what’s happening?
Well, let’s take a look at the test plugin code for our completefunc
,
attest#CompleteAsync
:
" See :help complete-functions
function! attest#CompleteAsync( findstart, base ) abort
if a:findstart
" We will work out the start position later
return s:FindStart()
endif
" Kill any existing request
call s:KillTimer()
" Kill the timer when leaving insert mode
augroup ATestClear
au InsertLeave * ++once call <SID>KillTimer()
augroup END
" Do something complicated that takes time. Pass the current column (actually
" the start column) and the 'query' (a:base) to the callback using a partial.
let s:complete_timer = timer_start( 200,
\ function( "s:DoAsyncCompletion",
\ [ col( '.' ), a:base ] ) )
return v:none
endfunction
To summarise:
- In phase 1, just return the start column synchronously
- In phase 2, start a timer to fire in 200ms time, and return
v:none
(this magic return value tells Vim we’re going to trigger the popup manually usingcomplete()
)
The timer callback actually populates the completion menu:
function! s:DoAsyncCompletion( start_col, base, id ) abort
call complete( a:start_col, s:CompleteMonth( a:base ) )
endfunction
For anyone not familiar with the syntax used above, the following creates a
partial (a Funcref
with some predefined arguments), and sets it as the
callback for the timer. The predefined arguments are the completion start
column, which is always the cursor column, col( '.' )
, in “phase 2” of
completion, and the ‘query’, a:base
:
let s:complete_timer = timer_start( 200,
\ function( "s:DoAsyncCompletion",
\ [ col( '.' ), a:base ] ) )
The predefined arguments are combined with the signature of the required timer callback (just a timer ID argument) in the signature or our actual callback:
function! s:DoAsyncCompletion( start_col, base, id ) abort
Staying in insert mode
Recall that our tests are simple Vim functions. They themselves execute synchronously, but our completion system won’t return the results (and thus display the popup menu) until at least 200ms after we triggered completion.
In fact, feedkeys()
exits insert mode after triggering user-defined completion,
so we actually cancel our timer and never even run the s:DoAsyncCompletion
method. So what do we do?
Our options are limited, but we need to stay in insert mode after triggering user-defined completion, and we also need to return to the Vim ‘event loop’ so that the timer can fire and trigger completion popup to be displayed.
The first is easy; we can pass the !
flag to feedkeys()
, which as we
discussed earlier will leave us in insert mode and hand over responsibility to
us to return to normal mode programmatically.
Timers to the rescue
The latter is a little more tricky. A naive, but effective approach is to start
our own timer in the tests which fires after the completion results are in.
This can then check the popup menu, and return to normal mode by running
feedkeys( "\<Esc>" )
. It would look something like this:
function Test_Async_Empty()
function CheckPopupContents( id )
let items = complete_info().items
call map( items, {index, value -> value.word} )
call assert_equal( [ 'Mar', 'May' ], items )
call feedkeys( "\<Esc>" )
endfunction
call timer_start( 400, function( "CheckPopupContents" ) )
call feedkeys( "iM\<C-x>\<C-u>", "xt!" )
call assert_equal( "Mar", getline( 1 ) )
delfunc! CheckPopupContents
%bwipe!
endfunction
You can try this out, and it will probably work, but ultimately this can lead to flaky tests. I include this because in some scenarios, it’s the only choice.
Autocommands to the rescue
As it happens, we can do better, based on the observation that the
CompleteChanged
autocommand is triggered when we call
complete()
and provide the completions. This is due us using the
default configuration of Vim which is that completeopt
does not contain
noselect
.
Therefore a fully robust solution looks like this:
function Test_Async_Empty()
function CheckPopupContents()
let items = complete_info().items
call map( items, {index, value -> value.word} )
call assert_equal( [ 'Mar', 'May' ], items )
call feedkeys( "\<Esc>" )
endfunction
augroup Test_Async_Empty
au CompleteChanged * call CheckPopupContents()
augroup END
call feedkeys( "iM\<C-x>\<C-u>", "xt!" )
call assert_equal( "Mar", getline( 1 ) )
augroup Test_Async_Empty
au!
augroup END
delfunc! CheckPopupContents
%bwipe!
endfunction
For the completion tests where only a single result is returned, no completion
menu is shown (in the default Vim configuration, menuone
is not set
in completeopt
). So how do we test that? Well in that case we don’t need to
check the popup contents, but simply wait for the completion to be inserted
automatically. This automatic insertion triggers the
CompleteDone
autocommand, so we can use that instead:
function Test_Async_February()
function ExitInsertMode()
call feedkeys( "\<Esc>" )
endfunction
augroup Test_Async
au CompleteDone * call ExitInsertMode()
augroup END
call feedkeys( "iF\<C-x>\<C-u>", 'xt!' )
call assert_equal( 'Feb', getline( 1 ) )
augroup Test_Async
au!
augroup END
delfunc! ExitInsertMode
%bwipe!
endfunction
Wrapping up
And there you have it. A reliable way to test asynchronous insert-mode completion plugins, and any other type of plugin.
You can view the completed tests here:
- Test “framework”
- Basic test for simple version
- Popup test for simple version
- Full test for async version
Hopefully the ideas and techniques here show some insight into the process of writing robust vimscript layer tests. Of course, there’s so much more to talk about and so much more to testing, but I like to think that this approach (or your own personally adapted version) can go a long way to both improving the quality of your plugins and your confidence in changing them.
Maybe it can even improve your knowledge and understanding of Vimternals.
Or perhaps it was even mildly entertaining.
Appendix: Further reading
:help testing.txt
is a great guide and includes a lot more detail about
assertion functions, running Vim in a terminal, etc.
Take a look at Vim’s
src/testdir/Makefile
for how Vim runs its own tests. Also you can take a look at the tests in
src/testdir/
for
inspiration on testing particular aspects of Vim functionality.
You can take a look at the full README for the YouCompleteMe test
suite and at the (significantly more complex) run_tests.vim
used there. There’s also a script used to run them and the
CI configuration. The CI also supports coverage testing using
covimerage.
Credit
This article is based on work I did to support vim-layer testing for 2 complex plugins:
Most, if not all of the actual content of this test “framework” is lifted and
reverse-engineered from Vim’s source tree in the src/testdir
directory. I have
simplified and minimised it for demonstrative purposes, but very little of it is
strictly original work. The article, test plugin and its tests are original
work.
What’s not covered
There are plenty of other things we could/should do in practice which are left out for brevity, including:
- Allowing per-test SetUp and TearDown functions.
- Allowing tests to be skipped by throwing ‘Skip:
’, catching that in this loop. - Catching and handling errors/exceptions in set up and tear down functions.
- Implementing a per-test global timeout to catch tests stuck in insert mode.
WaitForAssert
,RunVimInTerminal
, etc. utility methods.- Avoiding the
E325: ATTENTION
errors if you have the test file open (hint:vim --clean -S run_test.vim --cmd 'au SwapExists * let v:swapchoice = "e"' test_script.vim
)
As I said, this is not an article about testing. But it’s also not an article about making Vim plugins (the right way). So I have left out things like:
- Running the tests from make, or any other build system
- Building and testing in Vim in docker
- Continuous integration
- Installation testing and linting
- Code coverage testing
- Debugging
- etc.
All of those are covered in the aforementioned plugin codebases, so please check the further reading section if you’re interested in any of those things.
About the author
Ben Jackson is a software architect working in high performance/low latency financial trading systems software. He is the primary maintainer of YouCompleteMe, an all-language code completion and comprehension tool for Vim (and all time #vim whipping boy). Ben is also the author and maintainer of Vimspector, an (the only?) all-language graphical debugger for Vim.
If you want to contact him, you can find him in YCM’s Gitter channel and occasionally in #vim on Freenode.
Ben’s OSS work is not in any way associated with his employer nor do his views or opinions in any way represent those of his employer, his family or any of his alter egos.
This work is licensed under a Creative Commons Attribution 4.0 International License