Testing Neovim LSP plugins

  Wednesday, October 26, 2022 » Neovim

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.