| name | minitest-pbt-lua |
| description | This skill should be used when the user asks to write property-based tests, PBT, or pseudo property-based tests for a Neovim plugin using the MiniTest (mini.test) framework in Lua. Use when the user says things like "add property tests", "write a PBT for", "property based test for", "mini.test property", or "fuzz inputs for". |
| version | 1.0.0 |
Property-Based Testing with MiniTest for Neovim Plugins
A pattern for writing pseudo property-based tests (PBT) in Lua using mini.test, where test cases are curated hard-coded inputs iterated exhaustively against one or more properties. This is not a true PBT framework — there is no random generation — but it achieves the same goal: separating what inputs to test from what to assert.
Core Concept
Split the test into three concerns:
- Input generators — functions that produce exhaustive or edge-covering input sets
- Property cases — curated scenarios (different data shapes, buffer contents, configurations) that each run against every property
- Properties — assertions expressed as Lua strings evaluated inside a child neovim process
Properties and cases are combined in a nested loop: every property runs against every case.
File Structure
local FunctionName = {}
FunctionName.get_inputs = function(buf_contents) ... end
FunctionName.function_name__property_cases = {
{ name = '...', buf_contents = {...}, get_inputs = FunctionName.get_inputs, ... },
{ name = '...', buf_contents = {...}, get_inputs = FunctionName.get_inputs, ... },
}
FunctionName.properties = {}
FunctionName.properties.property_name = [[(function() ... return true end)()]]
T['function_name() properties'] = new_set()
for func_name, func in pairs(FunctionName.properties) do
for _, case in ipairs(FunctionName.function_name__property_cases) do
T['function_name() properties'][func_name .. ': ' .. case.name] = function()
child.lua([[_G.fixture.inputs = ...]], { case.get_inputs(case.buf_contents) })
child.lua([[
local bufnr = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, ...)
vim.api.nvim_set_current_buf(bufnr)
_G.fixture.bufnr = bufnr
_G.fixture.winnr = vim.api.nvim_get_current_win()
]], { case.buf_contents })
child.lua([[vim.b[_G.fixture.bufnr].some_data = ...]], { case.some_data })
local result = child.lua_get(func)
eq(result, true)
end
end
end
Input Generators
Generate inputs exhaustively, including boundary and invalid values. The assume pattern (see below) filters out invalid combinations inside the property.
FunctionName.get_cursors_set = function(buf_contents)
local set = {}
for i, v in ipairs(buf_contents) do
for j = 1, #v do
table.insert(set, { i, j - 1 })
end
end
return set
end
FunctionName.get_inputs = function(buf_contents)
local set = {}
for winline = -1, #buf_contents + 1 do
for row, line in ipairs(buf_contents) do
for col = -1, #line + 1 do
table.insert(set, { target_row = row, target_col = col, og_winline = winline })
end
end
end
table.insert(set, { target_row = 9999, target_col = 9999, og_winline = 9999 })
table.insert(set, { target_row = -9999, target_col = -9999, og_winline = -9999 })
return set
end
Property Structure
Properties are Lua strings ([[(function() ... end)()]]) evaluated in the child neovim via child.lua_get. They must return true (pass) or false (fail). They read from _G.fixture which was populated by the test loop before evaluation.
FunctionName.properties.property_name = [[(function()
local inputs = _G.fixture.inputs
local bufnr = _G.fixture.bufnr
local winnr = _G.fixture.winnr
for _, input in ipairs(inputs) do
-- [assume] skip invalid inputs vacuously
if input.value < 0 then goto continue end
-- call the function under test
M.some_function(bufnr, winnr, input.value)
-- assert the property
local actual = vim.api.nvim_win_get_cursor(winnr)
if actual[1] ~= input.expected_row then return false end
::continue::
end
return true
end)()]]
Assume Semantics
Use goto continue + ::continue:: to skip inputs that violate preconditions (analogous to assume/guard in real PBT frameworks). This makes the property vacuously true for those inputs rather than failing.
For single-value properties (no loop), use an early return true:
if precondition_not_met then return true end
Mocking
What to mock
Mock functions that:
- Are side-effectful and not under test (
vim.api.nvim_echo, vim.defer_fn, vim.cmd for file opens)
- Are plugin-internal functions on
M that the function under test calls but you want to isolate (M.setup_something, M.set_restview)
- Return runtime values that can't be known at fixture-setup time (
M.get_cursor_placement_current_buffer)
What NOT to mock
Do not mock native Neovim/Vim API functions (vim.api.*, vim.fn.*). These are the ground truth of the running environment. Mocking them undermines the value of the test. Let the child neovim execute them for real.
How to mock in a property string
Replace functions by direct Lua assignment inside the property string. Always save and restore originals if the mock changes global state that other properties or iterations might depend on.
FunctionName.properties.some_property = [[(function()
local winnr = _G.fixture.winnr
-- mock plugin-internal function; capture runtime value via closure
local current_cursor = nil
M.get_cursor_placement_current_buffer = function()
return { winnr = winnr, cursor = current_cursor }
end
-- mock side-effectful calls not under test
local orig_nvim_echo = vim.api.nvim_echo
local orig_defer_fn = vim.defer_fn
vim.api.nvim_echo = function() end
vim.defer_fn = function() end
-- mock plugin helpers
M.set_restview = function() end
M.setup_cursor_placement = function() end
local result = true
for _, input in ipairs(_G.fixture.inputs) do
current_cursor = input.cursor -- update closure each iteration
M.function_under_test(_G.fixture.bufnr, input.forward)
local new_row = vim.api.nvim_win_get_cursor(winnr)[1]
if not valid_rows[new_row] then result = false; break end
end
-- restore originals
vim.api.nvim_echo = orig_nvim_echo
vim.defer_fn = orig_defer_fn
return result
end)()]]
Passing State via _G.fixture
Use _G.fixture as the namespace for all data passed from the host test process into the child neovim. Populate it with separate child.lua(...) calls before evaluating the property.
child.lua([[_G.fixture = {}]])
child.lua([[_G.fixture.inputs = ...]], { case.get_inputs(case.buf_contents) })
child.lua([[
local bufnr = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, ...)
vim.api.nvim_set_current_buf(bufnr)
_G.fixture.bufnr = bufnr
_G.fixture.winnr = vim.api.nvim_get_current_win()
]], { case.buf_contents })
child.lua([[vim.b[_G.fixture.bufnr].some_buf_var = ...]], { case.some_data })
if case.use_alternative_bufnr then
child.lua([[_G.fixture.alternative_bufnr = vim.api.nvim_create_buf(true, true)]])
else
child.lua([[_G.fixture.alternative_bufnr = nil]])
end
Buffer Setup Best Practices
- Create buffers inside
child.lua (never pass bufnr from host — the integer won't match)
- Set the buffer as current immediately after creation so
nvim_get_current_win() reflects it
- Capture
winnr from inside the same child.lua call that sets the current buffer
- Set buffer-local variables (
vim.b[bufnr].x) in a separate child.lua call after the bufnr is in _G.fixture
Property Case Design Guidelines
Each case should represent a distinct scenario (not just different numeric inputs). Good axes for variation:
- Different data shapes: single hunk vs. multiple hunks, one file vs. two files
- Presence/absence of optional fields:
filepath = nil vs. filepath = 'src/foo.lua'
- Line types: context-only, added-only, removed-only, mixed
- Boundary conditions: empty buffer, single-line buffer, cursor at first/last row
- Known edge cases or documented bugs: document them in a comment even if the test can't catch them in headless mode
Aim for 3–5 cases. More cases slow the suite; fewer miss meaningful variation.
When Properties Can't Catch Real Bugs
Some bugs only manifest with an attached terminal (e.g., vim.fn.screenpos() returning 0 for off-screen lines in interactive mode but not in headless child neovim). When this happens:
- Document the limitation in a comment above the property
- Add an
assume (goto continue) to exclude the inputs that trigger the bug, rather than letting the property silently pass for wrong reasons
- Note the bug in the function's source comments for manual testing