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
-- ─────────────────────────────────────────────────────────────────
-- function_name() - property based tests
local FunctionName = {}
-- Input generator: returns a list of inputs to iterate in the property
FunctionName.get_inputs = function(buf_contents) ... end
-- Property cases: each case defines a distinct data shape / scenario
--- @class function_name__property_cases
FunctionName.function_name__property_cases = {
{ name = '...', buf_contents = {...}, get_inputs = FunctionName.get_inputs, ... },
{ name = '...', buf_contents = {...}, get_inputs = FunctionName.get_inputs, ... },
}
-- Properties: each is a Lua string that returns true/false when evaluated in child neovim
FunctionName.properties = {}
FunctionName.properties.property_name = [[(function() ... return true end)()]]
-- Test loop: cross-product of properties × cases
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()
-- pass inputs into child neovim via _G.fixture
child.lua([[_G.fixture.inputs = ...]], { case.get_inputs(case.buf_contents) })
-- set up buffer in child neovim
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 })
-- pass any additional fixture data
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.
-- Cursor-style: every (row, col) in the buffer, including zero-indexed cols
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
-- Numeric inputs: sweep a range including below-zero and beyond-max edge cases
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
-- extreme edge cases
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.cmdfor file opens) - Are plugin-internal functions on
Mthat 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.
-- scalars / tables serialized by MiniTest's RPC
child.lua([[_G.fixture = {}]]) -- reset between cases (or rely on child.restart())
child.lua([[_G.fixture.inputs = ...]], { case.get_inputs(case.buf_contents) })
-- runtime values (bufnr, winnr) must be captured inside child.lua, not passed as args
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 })
-- buffer-local variables
child.lua([[vim.b[_G.fixture.bufnr].some_buf_var = ...]], { case.some_data })
-- conditional fixture values (e.g. a bufnr that may or may not exist)
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
winnrfrom inside the samechild.luacall that sets the current buffer - Set buffer-local variables (
vim.b[bufnr].x) in a separatechild.luacall 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 = nilvs.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