← Back to DevBytes

Neovim Testing Integration: Complete Guide

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:

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:

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:

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:

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:

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

— Ad —

Google AdSense will appear here after approval

← Back to all articles