Testing Neovim LSP plugins
This is a short introduction in how you can test a Neovim plugin which extends the LSP functionality. This approach requires Neovim 0.8 or later.
Setup plenary ¶
To run tests you need a test runner. Busted is a popular choice for Lua, but for Neovim using the test harness that’s part of plenary is currently the more convenient option.
This is because Neovim embeds Lua and you’ll want to run your tests using this embedded “Neovim Lua” to have access to the vim
module. If you ran busted directly it would use your system Lua, without access to the vim
module.
In the future it may become possible to run the normal busted and tell it to use Neovim as Lua interpreter, if Neovim adds support for that. (See repurpose “-l” to execute Lua)
Meanwhile, install plenary:
git clone https://github.com/nvim-lua/plenary.nvim.git ~/.config/nvim/pack/plugins/start/
In your plugin create a tests/minimal.vim
file with:
set rtp+=.
set rtp+=../plenary.nvim
runtime! plugin/plenary.vim
To give it a go, create a tests/example_spec.lua
file with:
describe('Test example', function()
it('Test can access vim namespace', function()
assert.are.same(vim.trim(' a '), 'a')
end)
end)
Then run the plenary test runner:
nvim --headless --noplugin -u tests/minimal.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal.vim'}"
For this to work your plugin needs to be a sibling to plenary.nvim
. That means your folder structure needs to look like this:
~/.config/nvim/pack/plugins/start/
- plenary.nvim
- your-plugin
- tests/minimal.vim
- tests/example_spec.lua
You could use the nvim-lua-plugin-template to get the this basic boilerplate, including a Github Action workflow to run the tests.
A in-process LSP Server ¶
Neovim added TCP support for LSP in Neovim 0.8. As part of that change it decoupled the transport mechanism with which Neovim interacts with a language server process. This made it easier to implement a language server in Lua that runs in the Neovim process and communicates via function calls.
A minimal server implementation would look like this:
-- A server implementation is just a function that returns a table with several methods
-- `dispatchers` is a table with a couple methods that allow the server to interact with the client
local function server(dispatchers)
local closing = false
local srv = {}
-- This method is called each time the client makes a request to the server
-- `method` is the LSP method name
-- `params` is the payload that the client sends
-- `callback` is a function which takes two parameters: `err` and `result`
-- The callback must be called to send a response to the client
-- To learn more about what method names are available and the structure of
-- the payloads you'll need to read the specification:
-- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
function srv.request(method, params, callback)
if method == 'initialize' then
callback(nil, { capabilities = {} })
elseif method == 'shutdown' then
callback(nil, nil)
end
return true, 1
end
-- This method is called each time the client sends a notification to the server
-- The difference between `request` and `notify` is that notifications don't expect a response
function srv.notify(method, params)
if method == 'exit' then
dispatchers.on_exit(0, 15)
end
end
-- Indicates if the client is shutting down
function srv.is_closing()
return closing
end
-- Callend when the client wants to terminate the process
function srv.terminate()
closing = true
end
return srv
end
And you’d use it like this to create a client instance that uses this server:
vim.lsp.start({ name = 'my-server', cmd = server })
Putting the pieces together ¶
To put the pieces together, rename the example_spec.lua
to a more reasonable name, integrate the server logic and then call your plugins method.
One pattern I like to use it to trace the requests and assert them in tests.
For example you could extend the request
method of the server like this:
-- Outside of the server function:
local messages = {}
-- ...
function srv.request(method, params, callback)
table.insert(messages, {
method = method,
params = params
})
if method == 'initialize' then
callback(nil, { capabilities = {} })
elseif method == 'shutdown' then
callback(nil, nil)
else
-- You'll also need to add other cases to send the responses your client/test expects
-- ...
end
end
-- ...
And then you’d use that in your test case:
-- <insert the server logic here>
describe('my_plugin', function()
before_each(function()
-- reset the messages for each test run
messages = {}
end)
it('my plugin does something useful', function()
vim.lsp.start({ name = 'my-server', cmd = server })
require('my_plugin').do_something()
assert.are.same(2, #messages)
assert.are.same('method', messages[2].method)
end)
end)
LSP uses a callback style model to make operations asynchronous and non-blocking, because of this it may be necessary to use vim.wait
in your test-cases to wait for the operations to complete before making assertions.
You can take a look at a lsp-compl test case to see this in action in a real world use-case that’s more complete but still fairly small.