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 = { '', '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', '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', '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', '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', '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', 'tx', function() neotest.run.stop() notify('Stopped test run', vim.log.levels.WARN) end, { desc = 'Test: Stop' }) vim.keymap.set('n', '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', 'to', function() neotest.output.open({ auto_close = false }) end, { desc = 'Test: Show output' }) vim.keymap.set('n', '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 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 ta to discover tests', vim.log.levels.WARN) end end, { desc = 'Test: Prev failed' }) vim.keymap.set('n', 'tC', function() local ok, cov = pcall(require, 'coverage') if ok then cov.toggle() end end, { desc = 'Test: Toggle coverage' }) vim.keymap.set('n', 'tL', function() local ok, cov = pcall(require, 'coverage') if ok then cov.load(true) end end, { desc = 'Test: Load coverage' }) end, }, }