What is Neovim Testing Integration?
Neovim testing integration refers to the ecosystem of plugins, built-in features, and workflows that allow developers to run, debug, and manage tests directly from within the Neovim editor. Rather than switching to a separate terminal window to execute test suites, testing integration brings the full feedback loop into the editor—running tests, displaying results, navigating to failures, and even running tests on save—all without leaving your coding environment.
At its core, testing integration in Neovim builds upon several foundational capabilities:
- Terminal buffers — Neovim's built-in terminal emulator allows running test commands in a buffer that behaves like any other editor window
- Job control APIs — The Lua API provides
vim.fn.jobstart()and related functions for asynchronous process execution - Diagnostics subsystem — Test failures can be mapped to virtual text, signs, and the quickfix list using Neovim's native diagnostic infrastructure
- Extensible UI — Floating windows, popups, and status line components create rich, non-intrusive test result displays
Modern Neovim testing plugins combine these primitives into polished workflows that rival dedicated IDEs, supporting a wide array of test frameworks across languages—from pytest and jest to RSpec, Go test, and PHPUnit.
Why Testing Integration Matters
The traditional workflow of editing code in one window while running tests in a separate terminal introduces constant context switching. Each context switch carries a cognitive cost: you must locate the terminal, interpret output, find the failing file and line number, then navigate back to the editor. Over hundreds of test runs per day, this friction compounds into significant lost productivity and mental fatigue.
Testing integration matters because it:
- Collapses the feedback loop — See test results alongside your code, often within milliseconds of saving a file
- Enables true Test-Driven Development — The red-green-refactor cycle becomes fluid when you can run a single test with a keystroke and see the result inline
- Surfaces failures precisely — Jump directly to the assertion that failed, not just the test file, but the exact line
- Reduces debugging time — Stack traces and diffs appear in familiar, navigable buffers with syntax highlighting
- Encourages testing discipline — When running tests is effortless, developers write and run them more frequently
- Integrates with CI/CD mental models — Run the same test suite locally that runs in CI, with structured output you can act on
For teams practicing continuous integration, the ability to run a targeted subset of tests instantly—the single failing test, the current file's tests, or tests matching a tag—dramatically accelerates the "inner loop" of development before code ever reaches the CI pipeline.
How to Use Testing Integration in Neovim
Approach 1: Built-in Terminal + Quickfix List (No Plugins)
The simplest approach uses Neovim's built-in terminal and the quickfix list. While not as polished as dedicated plugins, this method works with any test framework and requires zero dependencies beyond Neovim itself.
Create a terminal buffer and run your test suite:
-- Open a split terminal and run tests
vim.cmd('split | terminal')
vim.fn.termopen('pytest -q --tb=short')
To parse test output into the quickfix list for navigation, use errorformat:
-- Set errorformat for pytest-style output
vim.bo.errorformat = '%E%f:%l: %m,%C%m,%Z[%p] %.%#,%C%.%#'
-- Run tests and capture output into quickfix
vim.cmd('split | terminal')
vim.fn.termopen('pytest -q --tb=short 2>&1 | tee /tmp/test-output.txt')
-- In a separate command:
vim.cmd('cgetfile /tmp/test-output.txt')
vim.cmd('copen')
This approach is functional but manual. The quickfix list populates with filenames and line numbers, and you can navigate with :cnext and :cprev. However, you must manually parse output, manage terminal buffers, and there's no per-test or per-file runner granularity.
Approach 2: vim-test — The Mature, Framework-Agnostic Runner
vim-test by Janko Marohnić is a battle-tested plugin that provides a unified interface for running tests across dozens of frameworks. It detects your test framework automatically and provides consistent commands regardless of language.
Install with your preferred plugin manager (example using lazy.nvim):
{
"vim-test/vim-test",
lazy = true,
keys = {
{ "<leader>tn", "<cmd>TestNearest<cr>", desc = "Run nearest test" },
{ "<leader>tf", "<cmd>TestFile<cr>", desc = "Run current test file" },
{ "<leader>ts", "<cmd>TestSuite<cr>", desc = "Run entire test suite" },
{ "<leader>tl", "<cmd>TestLast<cr>", desc = "Re-run last test" },
{ "<leader>tv", "<cmd>TestVisit<cr>", desc = "Visit test file" },
},
config = function()
vim.g["test#strategy"] = "neovim"
vim.g["test#neovim#term_position"] = "vertical"
-- Customize runner commands per language
vim.g["test#python#pytest#options"] = "-q --tb=short"
vim.g["test#javascript#jest#options"] = "--verbose false"
vim.g["test#ruby#rspec#options"] = "--format documentation"
end,
}
The plugin supports multiple execution strategies that control where and how test output appears:
basic— Uses:!command, blocking, output in messagesneovim— Uses Neovim's terminal in a split window (recommended)floaterm— Opens a floating terminal windowdispatch— Integrates withvim-dispatchfor asynchronous runsmake— Populates the quickfix list with results
The neovim strategy is particularly effective because it keeps the terminal buffer persistent, allowing you to inspect full output, re-run with TestLast, and copy stack traces:
-- Full vim-test configuration example
vim.g["test#strategy"] = "neovim"
vim.g["test#neovim#term_position"] = "belowright"
vim.g["test#neovim#term_size"] = 10
-- Project-specific test configuration
vim.g["test#project#root"] = "."
vim.g["test#custom_runners"] = {
python = "pytest -q --tb=short --color=yes",
javascript = "npx jest --no-coverage",
}
-- Auto-open test file for current source file
vim.g["test#preserve_screen"] = 0
vim-test excels at test-to-source navigation. The :TestVisit command opens the corresponding test file for the current source file (or vice versa), following your project's naming conventions. For a Rails project, it knows that app/models/user.rb maps to spec/models/user_spec.rb.
Approach 3: neotest — The Modern Lua-Native Test Runner
neotest is a Lua-native testing framework designed specifically for Neovim's modern API. Unlike vim-test's Vimscript heritage, neotest leverages asynchronous jobs, attaches test results to buffer locations, and provides a rich, extensible architecture with per-framework adapters.
Installation with adapters for common frameworks:
{
"nvim-neotest/neotest",
dependencies = {
"nvim-neotest/neotest-python", -- pytest adapter
"nvim-neotest/neotest-jest", -- Jest adapter
"nvim-neotest/neotest-go", -- Go test adapter
"nvim-neotest/neotest-rspec", -- RSpec adapter
"nvim-neotest/neotest-plenary", -- Lua/Busted adapter
"nvim-lua/plenary.nvim",
"nvim-treesitter/nvim-treesitter",
"MunifTanjim/nui.nvim",
},
config = function()
local neotest = require("neotest")
neotest.setup({
adapters = {
require("neotest-python")({
dap = { justMyCode = false },
args = { "-q", "--tb=short" },
}),
require("neotest-jest")({
jestCommand = "npx jest",
jestConfigFile = "jest.config.js",
}),
require("neotest-go"),
require("neotest-rspec"),
},
status = {
enabled = true,
signs = true,
virtual_text = true,
},
output = {
enabled = true,
open_on_run = "short",
},
summary = {
enabled = true,
open = "botright split",
},
})
-- Key mappings
vim.keymap.set("n", "<leader>nr", function()
neotest.run.run()
end, { desc = "Run nearest test" })
vim.keymap.set("n", "<leader>nf", function()
neotest.run.run(vim.fn.expand("%"))
end, { desc = "Run current file" })
vim.keymap.set("n", "<leader>ns", function()
neotest.run.run({ suite = true })
end, { desc = "Run entire suite" })
vim.keymap.set("n", "<leader>nl", function()
neotest.run.run_last()
end, { desc = "Re-run last test" })
vim.keymap.set("n", "<leader>no", function()
neotest.output.open({ enter = true })
end, { desc = "Open test output" })
vim.keymap.set("n", "<leader>na", function()
neotest.run.attach()
end, { desc = "Attach to running test" })
end,
}
Neotest's architecture revolves around several key concepts:
- Adapters — Framework-specific plugins that know how to discover tests, build run commands, and parse results. The adapter protocol is public, so you can write adapters for custom frameworks
- Test tree — A hierarchical representation of all tests in your project, discovered via adapters. This powers the summary window and enables running tests at any granularity (namespace, file, individual test)
- Result annotations — Test outcomes (pass/fail/error) are attached to buffer positions, appearing as virtual text and sign column indicators
- Output window — A dedicated floating or split window showing raw test output with syntax highlighting
- Summary window — A structured view of all test results, grouped by file and status, with key mappings to jump to failures
One of neotest's most powerful features is attaching to running tests. When you start a test run and navigate away, you can re-attach to see progress and results without blocking your workflow:
-- Attach to a running test process
require("neotest").run.attach()
-- Run with a callback to handle results programmatically
require("neotest").run.run({
vim.fn.expand("%"),
after = function(results)
local failed = 0
for _, result in ipairs(results) do
if result.status == "failed" then
failed = failed + 1
end
end
vim.notify(string.format("Tests complete: %d failures", failed))
end,
})
Neotest integrates seamlessly with Debug Adapter Protocol (DAP) for debugging tests. When an adapter supports DAP (like neotest-python with debugpy), you can run a test with debugging enabled and step through code with breakpoints:
-- Run nearest test with debugger attached
vim.keymap.set("n", "<leader>nd", function()
require("neotest").run.run({ strategy = "dap" })
end, { desc = "Debug nearest test" })
-- Run with specific DAP configuration
require("neotest").run.run({
strategy = "dap",
dap = { justMyCode = false, console = "integratedTerminal" },
})
Approach 4: Overseer + Test Integration — Task-Based Workflow
overseer.nvim is a general-purpose task runner that can be configured for test execution. It provides a unified task list UI, persistent task history, and rich output handling. When combined with test-specific configuration, it becomes a powerful testing dashboard.
{
"stevearc/overseer.nvim",
config = function()
local overseer = require("overseer")
overseer.setup({
strategy = "terminal",
templates = {
builtin = true,
},
})
-- Define test tasks
overseer.register_template({
name = "pytest file",
builder = function()
return {
cmd = { "pytest", "-q", "--tb=short", vim.fn.expand("%") },
name = "pytest: " .. vim.fn.expand("%:t"),
}
end,
})
overseer.register_template({
name = "pytest nearest",
builder = function()
local line = vim.fn.line(".")
return {
cmd = { "pytest", "-q", "--tb=short", vim.fn.expand("%") .. "::" .. line },
name = "pytest: line " .. line,
}
end,
})
vim.keymap.set("n", "<leader>otf", "<cmd>OverseerRun pytest file<cr>")
vim.keymap.set("n", "<leader>otn", "<cmd>OverseerRun pytest nearest<cr>")
end,
}
Overseer shines when you need to manage multiple concurrent test runs, view historical results, or integrate testing with other project tasks like linting and building.
Approach 5: Custom Lua-Based Test Runner
For developers who want maximum control, building a custom test runner using Neovim's Lua API is straightforward. This approach lets you tailor the experience precisely to your workflow.
-- lua/test-runner.lua
local M = {}
local function parse_pytest_output(output, filepath)
local results = {}
for line in output:gmatch("[^\r\n]+") do
-- Match pytest failure lines: file:line: message
local file, lnum, msg = line:match("^(%S+):(%d+): (.+)$")
if file and lnum then
table.insert(results, {
file = file,
lnum = tonumber(lnum),
message = msg,
status = "failed",
})
end
-- Match pass indicators
if line:match("passed") or line:match("PASSED") then
-- Track passing tests
end
end
return results
end
function M.run_test_file(filepath)
local cmd = string.format("pytest -q --tb=short %s", filepath)
vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
local output = table.concat(data, "\n")
local results = parse_pytest_output(output, filepath)
-- Populate quickfix list
local qflist = {}
for _, result in ipairs(results) do
table.insert(qflist, {
filename = result.file,
lnum = result.lnum,
text = result.message,
})
end
vim.fn.setqflist(qflist, "r")
-- Show summary notification
local failed = #qflist
if failed == 0 then
vim.notify("All tests passed!", vim.log.levels.INFO)
else
vim.notify(
string.format("%d test(s) failed — use :cnext to navigate", failed),
vim.log.levels.WARN
)
end
end
end,
on_stderr = function(_, data)
if data and #data > 0 then
vim.schedule(function()
vim.notify("Test run error: " .. table.concat(data, "\n"), vim.log.levels.ERROR)
end)
end
end,
on_exit = function(_, exit_code)
vim.schedule(function()
if exit_code == 0 then
vim.notify("Test suite passed with exit code 0", vim.log.levels.INFO)
end
end)
end,
stdout_buffered = true,
stderr_buffered = true,
})
end
function M.run_nearest_test()
local filepath = vim.fn.expand("%")
local line = vim.fn.line(".")
local cmd = string.format("pytest -q --tb=short %s::%d", filepath, line)
vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
vim.schedule(function()
local buf = vim.api.nvim_create_buf(false, true)
local lines = vim.split(table.concat(data, "\n"), "\n")
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = math.floor(vim.o.columns * 0.6),
height = math.floor(vim.o.lines * 0.4),
row = math.floor(vim.o.lines * 0.3),
col = math.floor(vim.o.columns * 0.2),
style = "minimal",
border = "rounded",
})
vim.api.nvim_set_option_value("syntax", "pytest", { buf = buf })
end)
end
end,
stdout_buffered = true,
})
end
-- Create user commands
vim.api.nvim_create_user_command("TestFile", function()
M.run_test_file(vim.fn.expand("%"))
end, {})
vim.api.nvim_create_user_command("TestNearest", function()
M.run_nearest_test()
end, {})
vim.keymap.set("n", "<leader>tf", "<cmd>TestFile<cr>", { desc = "Run test file" })
vim.keymap.set("n", "<leader>tn", "<cmd>TestNearest<cr>", { desc = "Run nearest test" })
return M
This custom runner demonstrates key patterns: asynchronous job execution with vim.fn.jobstart, buffered output collection, quickfix list population for navigation, floating windows for results display, and user command creation for key mapping integration.
Configuring Diagnostic Integration
To make test failures visible inline (similar to LSP diagnostics), you can populate Neovim's diagnostic API from test results:
-- Integrate test failures with diagnostics
local function attach_test_diagnostics(results)
local namespace = vim.api.nvim_create_namespace("test-failures")
local diagnostics = {}
for _, result in ipairs(results) do
if result.status == "failed" then
table.insert(diagnostics, {
lnum = result.lnum - 1, -- 0-indexed
col = 0,
severity = vim.diagnostic.severity.ERROR,
message = result.message,
source = "test",
})
end
end
-- Clear old diagnostics and set new ones
vim.diagnostic.reset(namespace, 0)
if #diagnostics > 0 then
vim.diagnostic.set(namespace, 0, diagnostics)
vim.diagnostic.show({
namespace = namespace,
virt_text = true,
underline = true,
signs = true,
})
end
end
-- Run test and feed results to diagnostics
function M.run_with_diagnostics()
local filepath = vim.fn.expand("%")
local cmd = string.format("pytest -q --tb=short --json-report --json-report-file=/tmp/pytest.json %s", filepath)
vim.fn.jobstart(cmd, {
on_exit = function(_, exit_code)
if exit_code ~= 0 then
-- Read JSON report and parse failures
local json = vim.fn.json_decode(vim.fn.readfile("/tmp/pytest.json"))
local results = {}
if json and json.tests then
for _, test in ipairs(json.tests) do
if test.outcome == "failed" then
-- Extract file and line from traceback
local tb = test.traceback or ""
local file, line = tb:match('File "(.-)", line (%d+)')
if file and line then
table.insert(results, {
status = "failed",
file = file,
lnum = tonumber(line),
message = test.message or "Test failed",
})
end
end
end
end
attach_test_diagnostics(results)
end
end,
})
end
Best Practices for Neovim Testing Integration
1. Choose the Right Granularity for Key Bindings
Design your key mappings to support the most common testing workflows you actually use. A recommended set covers these operations:
- Run nearest test — The test under the cursor, typically the most frequent operation during TDD
- Run current file — All tests in the active buffer
- Run last test — Re-run whatever was executed last, essential for the TDD red-green cycle
- Run suite — The full test suite, typically before committing
- Toggle output window — Show or hide detailed test output
- Jump to failure — Navigate to the next failing test assertion
A clean mapping convention uses <leader>t as the testing namespace:
-- Recommended key binding convention
vim.keymap.set("n", "<leader>tn", "<cmd>TestNearest<cr>", { desc = "[t]est [n]earest" })
vim.keymap.set("n", "<leader>tf", "<cmd>TestFile<cr>", { desc = "[t]est [f]ile" })
vim.keymap.set("n", "<leader>ts", "<cmd>TestSuite<cr>", { desc = "[t]est [s]uite" })
vim.keymap.set("n", "<leader>tl", "<cmd>TestLast<cr>", { desc = "[t]est [l]ast" })
vim.keymap.set("n", "<leader>to", "<cmd>TestOutput<cr>", { desc = "[t]est [o]utput" })
vim.keymap.set("n", "<leader>tj", function()
vim.diagnostic.goto_next({ severity = vim.diagnostic.severity.ERROR })
end, { desc = "[t]est [j]ump to failure" })
2. Integrate Testing with Your Status Line
Surface test status in your status line to maintain awareness without checking explicitly. Many status line plugins support custom components:
-- Example for lualine.nvim
local test_status = {
"test-status",
icon = "🧪",
color = { fg = "#ffcc00" },
cond = function()
return vim.b.test_results ~= nil
end,
}
-- Update test status after runs
local function update_test_status(results)
local passed = 0
local failed = 0
for _, r in ipairs(results) do
if r.status == "passed" then passed = passed + 1 end
if r.status == "failed" then failed = failed + 1 end
end
vim.b.test_results = {
passed = passed,
failed = failed,
icon = failed == 0 and "✅" or "❌",
}
end
3. Use Autocommands for Continuous Feedback
Configure tests to run automatically on file save for immediate feedback. This pattern, sometimes called "continuous testing," keeps the feedback loop as tight as possible:
-- Auto-run tests on save for specific file types
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = { "*.py", "*.js", "*.ts", "*.rb", "*.go", "*.rs" },
callback = function()
-- Debounce: only run if no save occurred in the last 2 seconds
local timer = vim.b._test_timer
if timer then
vim.fn.timer_stop(timer)
end
vim.b._test_timer = vim.fn.timer_start(2000, function()
vim.schedule(function()
require("neotest").run.run(vim.fn.expand("%"))
end)
end)
end,
})
-- Alternatively, run only if a test file was modified
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = { "*_test.*", "*_spec.*", "test_*.py", "*.test.*" },
callback = function()
vim.schedule(function()
require("neotest").run.run(vim.fn.expand("%"))
end)
end,
})
Be cautious with auto-run on large test suites—limit it to the current file or use debouncing to avoid overwhelming your test runner.
4. Master Test-Source File Navigation
Quickly switching between source files and their corresponding test files is a high-frequency operation. Configure reliable navigation commands:
-- Toggle between source and test file
function _G.toggle_test_file()
local current = vim.fn.expand("%")
local test_patterns = {
["spec/(.*)_spec%.rb"] = "app/%1.rb",
["app/(.*)%.rb"] = "spec/%1_spec.rb",
["src/(.*)%.ts"] = "src/__tests__/%1.test.ts",
["src/(.*)%.py"] = "tests/test_%1.py",
["tests/test_(.*)%.py"] = "src/%1.py",
}
for pattern, replacement in pairs(test_patterns) do
local target = current:gsub(pattern, replacement)
if target ~= current and vim.fn.filereadable(target) == 1 then
vim.cmd("edit " .. target)
return
end
end
vim.notify("No corresponding test/source file found", vim.log.levels.WARN)
end
vim.keymap.set("n", "<leader>tt", _G.toggle_test_file, { desc = "[t]est [t]oggle file" })
5. Configure Framework-Specific Optimizations
Each test framework has unique capabilities worth exploiting. Invest time in framework-specific configuration:
-- Pytest: use --last-failed for rapid re-runs
vim.g["test#python#pytest#options"] = "--quiet --tb=short --last-failed --failed-first"
-- Jest: run in band for debugging, use --watch for development
vim.g["test#javascript#jest#options"] = "--no-coverage --verbose false"
-- RSpec: use --only-failures and --next-failure
vim.g["test#ruby#rspec#options"] = "--only-failures --format progress"
-- Go test: use -count=1 to disable caching
vim.g["test#go#gotest#options"] = "-count=1 -v -race"
6. Leverage Test Tags and Filtering
Most frameworks support tag-based filtering. Map convenient shortcuts to run subsets of tests by tag:
-- Run tests matching specific tags
vim.api.nvim_create_user_command("TestTag", function(opts)
local tag = opts.args
if tag == "" then
vim.notify("Usage: TestTag <tag_name>", vim.log.levels.WARN)
return
end
local cmd = string.format("pytest -q -m %s", tag)
vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
if data then
vim.schedule(function()
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(table.concat(data, "\n"), "\n"))
vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = 80,
height = 20,
row = 5,
col = 5,
border = "rounded",
})
end)
end
end,
})
end, { nargs = 1 })
-- Key mapping to prompt for tag
vim.keymap.set("n", "<leader>tT", "<cmd>TestTag <cr>", { desc = "[t]est by [T]ag" })
7. Keep Test Output Organized and Searchable
Long test output becomes unwieldy. Use folds, syntax highlighting, and persistent output buffers:
-- Configure output buffer with folds and syntax
vim.api.nvim_create_autocmd("BufRead", {
pattern = { "neotest-output*", "test-output*" },
callback = function()
vim.bo.buftype = "nofile"
vim.bo.bufhidden = "wipe"
vim.bo.swapfile = false
-- Enable folding on test result sections
vim.bo.foldmethod = "expr"
vim.bo.foldexpr = "v:lua.vim.treesitter.foldexpr()"
-- Set syntax if possible
pcall(function()
vim.cmd("set syntax=pytest")
end)
-- Key mappings within output buffer
vim.keymap.set("n", "q", "<cmd>close<cr>", { buffer = true, desc = "Close output" })
vim.keymap.set("n", "<CR>", function()
local line = vim.fn.getline(".")
local file, lnum = line:match("(%S+):(%d+):")
if file and lnum then
vim.cmd("edit +" .. lnum .. " " .. file)
end
end, { buffer = true, desc = "Jump to failure location" })
end,
})
8. Integrate with Git Hooks for Pre-Commit Testing
Run tests automatically before commits to catch regressions early. Use Neovim's job API to trigger pre-commit test runs:
-- Function to run tests on staged files before commit
function _G.pre_commit_test_run()
-- Get list of staged files
vim.fn.jobstart("git diff --cached --name-only --diff-filter=ACM", {
on_stdout = function(_, data)
if not data then return end
vim.schedule(function()
local staged = {}
for _, line in ipairs(data) do
if line:match("%.py$") or line:match("%.js$") or line:match("%.ts$") then
-- Find corresponding test files
local test_file = line:gsub("src/", "tests/"):gsub("%.py$", "_test.py")
if vim.fn.filereadable(test_file) == 1 then
table.insert(staged, test_file)
end
end
end
if #staged > 0 then
local cmd = "pytest -q " .. table.concat(staged, " ")
vim.fn.jobstart(cmd, {
on_exit = function(_, code)
vim.schedule(function()
if code == 0 then
vim.notify("Pre-commit tests passed ✅", vim.log.levels.INFO)
else