| name | minitest-integration-lua |
| description | This skill should be used when the user asks to write integration tests for a Neovim plugin using the MiniTest (mini.test) framework in Lua. Use when the user says things like "add integration tests", "write an integration test for", "test against the real dependency", "end-to-end test", "integration test with fzf/telescope/etc", or "test the full flow". |
| version | 1.0.0 |
Integration Testing with MiniTest for Neovim Plugins
A pattern for writing integration tests for Neovim plugins in Lua using mini.test. Integration tests exercise the full stack with real dependencies — no mocks unless a specific non-happy-path case demands one. They test observable outcomes (buffer state, window layout, keymaps bound, commands that work) rather than internal implementation details.
Core Principles
- No mocks by default — load real dependencies via
runtimepath; let the plugin run against them as it would in production.
- Use mocks only for non-happy-path cases — e.g. to force a failure branch that can't be triggered with real inputs (a git command that always succeeds in a real repo).
- Test happy paths and common failure cases — not exhaustive edge cases (those belong in unit tests).
- Tests are feature-level — one
T['FeatureName integration'] set per user-visible feature or command, not per internal function.
- Isolation via temporary state — create throwaway git repos, temp files, and temp dirs inside
child.lua using vim.fn.tempname(). Never rely on the test runner's working directory having the right git state.
- Print capture — always install the print-capture shim so debug output is visible on failure.
File Header
local new_set = MiniTest.new_set
local eq = MiniTest.expect.equality
local child = MiniTest.new_child_neovim()
Print Capture Utility
Same shim as unit tests — paste verbatim near the top:
local test_logging = [[
_G.test_logs = {}
_G.captured_prints = {}
local original_print = print
print = function(...)
local args = {...}
local msg = table.concat(vim.tbl_map(tostring, args), ' ')
table.insert(_G.captured_prints, msg)
original_print(...) -- Still call original for child's output
end
]]
local print_test_logging = function()
local captured_prints = child.lua_get('_G.captured_prints')
if captured_prints and #captured_prints > 0 then
print('\n=== Child Neovim Print Statements ===')
for i, msg in ipairs(captured_prints) do
print(string.format('[%d] %s', i, msg))
end
print('=== End Print Statements ===\n')
end
end
Top-Level Set (T)
Integration tests have a simpler top-level setup than unit tests: restart child, load the plugin (no mocks), install logging. No package.loaded stubs here.
local T = new_set({
hooks = {
pre_case = function()
child.restart({ '-u', 'scripts/minimal_init.lua' })
child.lua([[M = require('myplugin')]])
child.lua(test_logging)
end,
post_case = print_test_logging,
post_once = child.stop,
},
})
Key difference from unit tests: no child.lua([[package.loaded[...] = ...]]) here. The minimal_init.lua already adds real deps to rtp.
Suite Separator Comments
Use the full-width ── banner. Label each suite by the user-visible command or feature it exercises.
Temporary Git Repo Fixtures
Integration tests that touch git need a real, isolated git repo. Build one inside a Lua string that is executed with child.lua(...). Store the pattern as a local variable so multiple tests can reuse it.
Minimal single-file repo
local setup_tmpdir_git_repo = [[
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.fn.system('git -C ' .. tmpdir .. ' init')
vim.fn.system('git -C ' .. tmpdir .. ' config user.email "test@test.com"')
vim.fn.system('git -C ' .. tmpdir .. ' config user.name "Test"')
local f = io.open(tmpdir .. '/test.lua', 'w')
f:write('local x = 1\nlocal y = 2\n')
f:close()
vim.fn.system('git -C ' .. tmpdir .. ' add test.lua')
vim.fn.system('git -C ' .. tmpdir .. ' commit -m "initial"')
-- Make a working-tree change
local f2 = io.open(tmpdir .. '/test.lua', 'w')
f2:write('local x = 1\nlocal y = 99\n')
f2:close()
vim.cmd('cd ' .. tmpdir)
vim.cmd('edit ' .. tmpdir .. '/test.lua')
]]
Parameterised: N tracked files with changes
Pass n via the vararg ... so the same fixture can set up repos of different sizes:
local setup_tmpdir_git_repo_n_files = [[
local n = ...
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.fn.system('git -C ' .. tmpdir .. ' init')
vim.fn.system('git -C ' .. tmpdir .. ' config user.email "test@test.com"')
vim.fn.system('git -C ' .. tmpdir .. ' config user.name "Test"')
for i = 1, n do
local fname = 'file' .. i .. '.lua'
local f = io.open(tmpdir .. '/' .. fname, 'w')
f:write('local x = ' .. i .. '\n')
f:close()
vim.fn.system('git -C ' .. tmpdir .. ' add ' .. fname)
end
vim.fn.system('git -C ' .. tmpdir .. ' commit -m "initial"')
for i = 1, n do
local fname = 'file' .. i .. '.lua'
local f = io.open(tmpdir .. '/' .. fname, 'w')
f:write('local x = ' .. (i * 10) .. '\n')
f:close()
end
vim.cmd('cd ' .. tmpdir)
]]
Scenario fixture: specific file content for a known edge case
When an integration test targets a specific real-world failure mode, build the exact before/after file content inside the fixture string:
local setup_tmpdir_specific_scenario = [[
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.fn.system('git -C ' .. tmpdir .. ' init')
vim.fn.system('git -C ' .. tmpdir .. ' config user.email "test@test.com"')
vim.fn.system('git -C ' .. tmpdir .. ' config user.name "Test"')
local before_lines = {
'local function foo()',
' return 1',
'end',
}
local f = io.open(tmpdir .. '/mod.lua', 'w')
f:write(table.concat(before_lines, '\n') .. '\n')
f:close()
vim.fn.system('git -C ' .. tmpdir .. ' add mod.lua')
vim.fn.system('git -C ' .. tmpdir .. ' commit -m "initial"')
local after_lines = {
'local function foo()',
' return 42', -- changed line
'end',
'',
'local function bar() end', -- added function
}
local f2 = io.open(tmpdir .. '/mod.lua', 'w')
f2:write(table.concat(after_lines, '\n') .. '\n')
f2:close()
vim.cmd('cd ' .. tmpdir)
vim.cmd('edit ' .. tmpdir .. '/mod.lua')
]]
Fixture design rules:
- Always set
user.email and user.name — git refuses to commit without them.
- Always
vim.cmd('cd ' .. tmpdir) so the plugin's cwd-relative git calls resolve correctly.
- Always open the file with
vim.cmd('edit ...') if the plugin needs a current buffer.
- Use
io.open + f:write + f:close for file creation — vim.fn.writefile also works.
Suite and Test Structure
T['MyCommand integration'] = new_set({
hooks = {
pre_case = function()
child.lua(setup_tmpdir_git_repo)
end,
},
})
T['MyCommand integration']['happy path: creates buffer with expected state'] = function()
child.cmd('MyCommand HEAD')
local has_data = child.lua_get('vim.b[vim.api.nvim_get_current_buf()].my_data ~= nil')
local buf_on_window = child.lua_get('vim.api.nvim_win_get_buf(0) == vim.api.nvim_get_current_buf()')
local name = child.lua_get('vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())')
eq(has_data, true)
eq(buf_on_window, true)
eq(name:find('HEAD', 1, true) ~= nil, true)
eq(name:find('test.lua', 1, true) ~= nil, true)
end
What to assert in integration tests:
- Buffer-local variables set by the plugin (e.g.
vim.b[buf].my_data ~= nil)
- The buffer is visible in the expected window (
nvim_win_get_buf)
- Buffer name encodes key info (file path, ref, hunk count, etc.)
- A terminal buffer exists when the feature opens an external picker
- Keymaps are bound on the right buffer
What NOT to assert:
- Internal data structure shapes (that belongs in unit tests)
- Exact line content of the buffer (too fragile; verify structure instead)
Testing Branching Behavior via Config
When the plugin has configuration that changes the code path taken (e.g. a threshold that switches between two pickers), set the config before the command and assert the observable difference:
T['MyCommand integration']['picker A path: opens terminal when above threshold'] = function()
child.lua([[M.setup({ picker_threshold = 6 })]])
child.lua(setup_tmpdir_git_repo_n_files, { 7 })
child.cmd('MyCommand HEAD')
local has_terminal = child.lua_get([[
(function()
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[buf].buftype == 'terminal' then return true end
end
return false
end)()
]])
eq(has_terminal, true)
end
T['MyCommand integration']['picker B path: quickselect when below threshold'] = function()
child.lua([[M.setup({ picker_threshold = 6 })]])
child.lua(setup_tmpdir_git_repo_n_files, { 3 })
child.cmd('MyCommand HEAD')
child.type_keys('<CR>')
local has_data = child.lua_get('vim.b[vim.api.nvim_get_current_buf()].my_data ~= nil')
eq(has_data, true)
end
Using Mocks in Integration Tests
Inline mocks in integration tests are the exception, not the rule. Use them only to:
- Force a failure path that can't be triggered via real inputs (e.g. a git command that would always succeed in a real repo)
- Reproduce a known real-world bug scenario that requires specific tool output
When you do mock, apply the mock inside the individual test, not in a suite-level hook:
T['MyCommand integration']['failure path: notify called when dependency returns error'] = function()
child.lua([[
_G.fixture.notify_called = false
vim.notify = function(_msg, _level)
_G.fixture.notify_called = true
end
-- Force the failure by making the dependency return an error code
vim.system = function(_cmd, _opts)
return { wait = function() return { code = 128, stdout = '', stderr = 'fatal' } end }
end
]])
child.cmd('MyCommand HEAD')
local notify_called = child.lua_get('_G.fixture.notify_called')
eq(notify_called, true)
end
Adding a New Dependency
When the plugin integrates with a new Neovim plugin (e.g. a picker, a UI library), follow these steps:
1. Add to Makefile — clone the dep into deps/
In the setup and setup-silent targets, add an entry for the new dependency alongside existing ones:
@if [ ! -d "deps/newdep" ]; then \
echo "Installing newdep for integration tests..."; \
git clone --filter=blob:none https://github.com/author/newdep deps/newdep; \
else \
echo "newdep already installed"; \
fi
@[ -d "deps/newdep" ] || git clone -q --filter=blob:none https://github.com/author/newdep deps/newdep
Use --filter=blob:none on both to avoid downloading full history.
2. Add to scripts/minimal_init.lua — put it on runtimepath
vim.cmd('set rtp+=deps/newdep')
Add it after the existing rtp lines, before require('mini.test').setup(). The order matters if dependencies have their own dependencies — put prerequisites first.
Pattern for minimal_init.lua:
vim.cmd([[let &rtp.=','.getcwd()]])
if #vim.api.nvim_list_uis() == 0 then
vim.cmd('set rtp+=deps/mini.test')
vim.cmd('set rtp+=deps/existing-dep-1')
vim.cmd('set rtp+=deps/existing-dep-2')
vim.cmd('set rtp+=deps/newdep')
require('mini.test').setup()
end
3. Write the integration test
Create a new suite in the integration test file. The dependency is now available in the child neovim because minimal_init.lua (loaded via child.restart({ '-u', 'scripts/minimal_init.lua' })) puts it on the rtp:
T['MyCommand newdep integration'] = new_set({
hooks = {
pre_case = function()
child.lua(setup_tmpdir_git_repo)
end,
},
})
T['MyCommand newdep integration']['happy path: newdep picker opens'] = function()
child.lua([[M.setup({ picker = 'newdep' })]])
child.cmd('MyCommand HEAD')
local picker_open = child.lua_get([[require('newdep').is_open()]])
eq(picker_open, true)
end
4. Run and verify
make clean && make setup
make test
make test-file FILE=tests/myplugin/test_integrations.lua
Complete Example
local new_set = MiniTest.new_set
local eq = MiniTest.expect.equality
local child = MiniTest.new_child_neovim()
local test_logging = [[
_G.test_logs = {}
_G.captured_prints = {}
local original_print = print
print = function(...)
local args = {...}
local msg = table.concat(vim.tbl_map(tostring, args), ' ')
table.insert(_G.captured_prints, msg)
original_print(...)
end
]]
local print_test_logging = function()
local captured_prints = child.lua_get('_G.captured_prints')
if captured_prints and #captured_prints > 0 then
print('\n=== Child Neovim Print Statements ===')
for i, msg in ipairs(captured_prints) do
print(string.format('[%d] %s', i, msg))
end
print('=== End Print Statements ===\n')
end
end
local T = new_set({
hooks = {
pre_case = function()
child.restart({ '-u', 'scripts/minimal_init.lua' })
child.lua([[M = require('myplugin')]])
child.lua(test_logging)
end,
post_case = print_test_logging,
post_once = child.stop,
},
})
local setup_tmpdir_git_repo = [[
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.fn.system('git -C ' .. tmpdir .. ' init')
vim.fn.system('git -C ' .. tmpdir .. ' config user.email "test@test.com"')
vim.fn.system('git -C ' .. tmpdir .. ' config user.name "Test"')
local f = io.open(tmpdir .. '/test.lua', 'w')
f:write('local x = 1\nlocal y = 2\n')
f:close()
vim.fn.system('git -C ' .. tmpdir .. ' add test.lua')
vim.fn.system('git -C ' .. tmpdir .. ' commit -m "initial"')
local f2 = io.open(tmpdir .. '/test.lua', 'w')
f2:write('local x = 1\nlocal y = 99\n')
f2:close()
vim.cmd('cd ' .. tmpdir)
vim.cmd('edit ' .. tmpdir .. '/test.lua')
]]
T['DiffView integration'] = new_set({
hooks = {
pre_case = function()
child.lua(setup_tmpdir_git_repo)
end,
},
})
T['DiffView integration']['happy path: creates diff buffer with data and correct name'] = function()
child.cmd('DiffView HEAD')
local has_data = child.lua_get('vim.b[vim.api.nvim_get_current_buf()].diff_data ~= nil')
local buf_on_window = child.lua_get('vim.api.nvim_win_get_buf(0) == vim.api.nvim_get_current_buf()')
local name = child.lua_get('vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())')
eq(has_data, true)
eq(buf_on_window, true)
eq(name:find('HEAD', 1, true) ~= nil, true)
eq(name:find('test.lua', 1, true) ~= nil, true)
end
return T
Checklist Before Submitting Integration Tests