nvim/lua/custom/plugins/neotest.lua
Jeremie Fraeys 375584629e
Some checks failed
Luacheck / luacheck (push) Failing after 10s
StyLua / stylua (push) Successful in 3s
feat(neotest): add notifications, smart test discovery, fix DAP integration, and improve keymaps
- Add notify function for user feedback on all test operations
- Make <leader>tn smart: runs nearest in test files, finds matching test file from source
- Add same smart logic to <leader>td (debug) and <leader>tw (watch)
- Fix <leader>ts to auto-focus summary window for Enter navigation
- Add summary keymaps for <CR> to expand/jump to tests
- Add nvim-dap as dependency to fix debug strategy
- Fix DAP UI to not auto-close and not enter insert mode
- Fix watch to load test file buffer for LSP attachment
- Make python path detection use VIRTUAL_ENV env var first
- Fix neotest-python adapter configuration
2026-02-09 13:42:17 -05:00

240 lines
7.9 KiB
Lua

return {
{
'nvim-neotest/neotest',
dependencies = {
'nvim-lua/plenary.nvim',
'nvim-neotest/nvim-nio',
'nvim-treesitter/nvim-treesitter',
'nvim-neotest/neotest-python',
'alfaix/neotest-gtest',
'andythigpen/nvim-coverage',
'mfussenegger/nvim-dap', -- Required for debug strategy
},
event = 'VeryLazy',
config = function()
local neotest = require('neotest')
neotest.setup({
adapters = {
require('neotest-python')({
runner = 'pytest',
args = { '-v', '--tb=short' },
python = function()
-- Check if VIRTUAL_ENV is set (activated venv)
local venv = os.getenv('VIRTUAL_ENV')
if venv then
return venv .. '/bin/python'
end
-- Try common venv names
local venv_paths = {
'.venv/bin/python',
'venv/bin/python',
'.env/bin/python',
'env/bin/python',
}
for _, path in ipairs(venv_paths) do
if vim.fn.filereadable(path) == 1 then
return path
end
end
-- Fallback to system python
return vim.fn.exepath('python3') or vim.fn.exepath('python') or 'python'
end,
}),
require('neotest-gtest'),
},
output = { open_on_run = false },
summary = {
open = 'botright vsplit | vertical resize 60',
mappings = {
expand = { '<CR>', 'l' },
expand_all = 'E',
output = 'o',
short = 'O',
run = 'r',
debug = 'd',
mark = 'm',
run_marked = 'R',
clear_marked = 'M',
target = 't',
},
},
})
local ok_cov, coverage = pcall(require, 'coverage')
if ok_cov then
coverage.setup({
auto_reload = true,
})
end
-- Keymaps (defined after setup to ensure modules are available)
local function notify(msg, level)
local ok, notify_mod = pcall(require, 'notify')
if ok then
notify_mod(msg, level or vim.log.levels.INFO)
else
vim.notify(msg, level or vim.log.levels.INFO)
end
end
vim.keymap.set('n', '<leader>tn', function()
local file = vim.fn.expand('%:p')
local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$')
if is_test then
neotest.run.run()
notify('Running nearest test')
else
-- Try to find corresponding test file
local basename = vim.fn.expand('%:t:r')
local test_patterns = {
'tests/test_' .. basename .. '.py',
'tests/' .. basename .. '_test.py',
'test_' .. basename .. '.py',
basename .. '_test.py',
}
for _, pattern in ipairs(test_patterns) do
if vim.fn.filereadable(pattern) == 1 then
neotest.run.run(pattern)
notify('Running test file: ' .. pattern)
return
end
end
notify('No test file found for ' .. basename, vim.log.levels.WARN)
end
end, { desc = 'Test: Run nearest or matching test file' })
vim.keymap.set('n', '<leader>tf', function()
neotest.run.run(vim.fn.expand('%'))
notify('Running file tests: ' .. vim.fn.expand('%:t'), vim.log.levels.INFO)
end, { desc = 'Test: Run file' })
vim.keymap.set('n', '<leader>ta', function()
neotest.run.run(vim.fn.getcwd())
notify('Running all tests in ' .. vim.fn.getcwd(), vim.log.levels.INFO)
end, { desc = 'Test: Run all (cwd)' })
vim.keymap.set('n', '<leader>td', function()
local file = vim.fn.expand('%:p')
local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$')
if is_test then
neotest.run.run({ strategy = 'dap' })
notify('Debugging nearest test')
else
-- Try to find corresponding test file
local basename = vim.fn.expand('%:t:r')
local test_patterns = {
'tests/test_' .. basename .. '.py',
'tests/' .. basename .. '_test.py',
'test_' .. basename .. '.py',
basename .. '_test.py',
}
for _, pattern in ipairs(test_patterns) do
if vim.fn.filereadable(pattern) == 1 then
neotest.run.run({ pattern, strategy = 'dap' })
notify('Debugging test file: ' .. pattern)
return
end
end
notify('No test file found for ' .. basename, vim.log.levels.WARN)
end
end, { desc = 'Test: Debug nearest or matching test file' })
vim.keymap.set('n', '<leader>tx', function()
neotest.run.stop()
notify('Stopped test run', vim.log.levels.WARN)
end, { desc = 'Test: Stop' })
vim.keymap.set('n', '<leader>ts', function()
neotest.summary.toggle()
-- Focus the summary window so Enter/click works
vim.defer_fn(function()
for _, win in ipairs(vim.api.nvim_list_wins()) do
local buf = vim.api.nvim_win_get_buf(win)
local name = vim.api.nvim_buf_get_name(buf)
if name:match('Neotest Summary') then
vim.api.nvim_set_current_win(win)
break
end
end
end, 50)
end, { desc = 'Test: Toggle and focus summary' })
vim.keymap.set('n', '<leader>to', function()
neotest.output.open({ auto_close = false })
end, { desc = 'Test: Show output' })
vim.keymap.set('n', '<leader>tw', function()
local file = vim.fn.expand('%')
local is_test = file:match('test_.*%.py$') or file:match('.*_test%.py$') or file:match('tests/.*%.py$')
local target_file
if is_test then
target_file = file
else
-- Find the matching test file
local basename = vim.fn.expand('%:t:r')
local test_patterns = {
'tests/test_' .. basename .. '.py',
'tests/' .. basename .. '_test.py',
'test_' .. basename .. '.py',
basename .. '_test.py',
}
for _, pattern in ipairs(test_patterns) do
if vim.fn.filereadable(pattern) == 1 then
target_file = pattern
break
end
end
end
if not target_file then
notify('No test file found', vim.log.levels.WARN)
return
end
-- Open the test file in background so LSP attaches, then start watching
local buf = vim.fn.bufadd(target_file)
vim.fn.bufload(buf)
vim.defer_fn(function()
neotest.watch.toggle(target_file)
notify('Watching: ' .. target_file)
end, 100)
end, { desc = 'Test: Watch file or matching test' })
vim.keymap.set('n', ']t', function()
local ok, _ = pcall(neotest.jump.next, { status = 'failed' })
if not ok then
notify('No tests found - run <leader>ta to discover tests', vim.log.levels.WARN)
end
end, { desc = 'Test: Next failed' })
vim.keymap.set('n', '[t', function()
local ok, _ = pcall(neotest.jump.prev, { status = 'failed' })
if not ok then
notify('No tests found - run <leader>ta to discover tests', vim.log.levels.WARN)
end
end, { desc = 'Test: Prev failed' })
vim.keymap.set('n', '<leader>tC', function()
local ok, cov = pcall(require, 'coverage')
if ok then
cov.toggle()
end
end, { desc = 'Test: Toggle coverage' })
vim.keymap.set('n', '<leader>tL', function()
local ok, cov = pcall(require, 'coverage')
if ok then
cov.load(true)
end
end, { desc = 'Test: Load coverage' })
end,
},
}