An IDE without testing integration is just a pretty text editor. This episode covers `neotest` — how to run and navigate tests directly from Neovim with adapters for Go, Python, and JavaScript/TypeScript, making the test suite part of the daily workflow instead of a separate ritual.

After discussing the comparison between custom configs and Neovim distros in episode 25 — and how to make Neovim a daily driver — in this episode we fill one of the gaps we have often overlooked: testing. Imagine you are fixing a bug in production code. The typical GUI IDE workflow is: edit file → press a key combination → test runs → results appear in a panel → jump to the failing line → fix it. Now imagine the same flow in Neovim — that is what we will build today.
Neotest is a testing framework for Neovim that turns your editor into an interactive test runner. It finds tests inside your code (thanks to Treesitter), runs them asynchronously, shows status in the gutter, and opens output — all without leaving the editor. Not just "running a command in the terminal" — it understands the structure of your tests like a real IDE.
Why does this matter in real work? Tests are an engineer's main safety net. The faster and easier it is to run tests, the more often we run them — and the faster we find bugs, the cheaper they cost. Engineers who integrate testing into their editor write code that is braver to refactor and less likely to shoot themselves in the foot. This episode will cover the neotest setup, adapters for Go, Python, and JavaScript/TypeScript, and a complete workflow from running a single test to the entire suite.
Neotest works with a core + adapter architecture. The core provides the UI, process management, and results. Adapters are the "bridges" that know how to discover and run tests for a specific language — for example, the Go adapter knows tests live in _test.go files, the Python adapter knows the pytest conventions, and so on.
The workflow goes roughly like this:
go test -run TestFoo, pytest test_foo.py, npm test -- --runInBand).This is the same pattern as the test runner extensions in modern IDEs — but running inside the Neovim you already know.
Like all plugins in this series, we install it via lazy.nvim. Neotest needs a few dependencies: plenary.nvim (utilities), nvim-nio (async library), and nvim-treesitter (for test position discovery).
{
"nvim-neotest/neotest",
event = "VeryLazy",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-neotest/nvim-nio",
"nvim-treesitter/nvim-treesitter",
},
keys = {
{ "<leader>tt", function() require("neotest").run.run(vim.fn.expand("%")) end, desc = "Run File (Neotest)" },
{ "<leader>tT", function() require("neotest").run.run(vim.uv.cwd()) end, desc = "Run All Tests (Neotest)" },
{ "<leader>tr", function() require("neotest").run.run() end, desc = "Run Nearest (Neotest)" },
{ "<leader>tl", function() require("neotest").run.run_last() end, desc = "Run Last (Neotest)" },
{ "<leader>ts", function() require("neotest").summary.toggle() end, desc = "Toggle Summary (Neotest)" },
{ "<leader>to", function() require("neotest").output.open({ enter = true, auto_close = true }) end, desc = "Show Output (Neotest)" },
{ "<leader>tO", function() require("neotest").output_panel.toggle() end, desc = "Toggle Output Panel (Neotest)" },
{ "<leader>tS", function() require("neotest").run.stop() end, desc = "Stop (Neotest)" },
{ "<leader>tw", function() require("neotest").watch.toggle(vim.fn.expand("%")) end, desc = "Toggle Watch (Neotest)" },
{ "]n", function() require("neotest").jump.next({ status = "failed" }) end, desc = "Next Failed Test" },
{ "[n", function() require("neotest").jump.prev({ status = "failed" }) end, desc = "Prev Failed Test" },
},
config = function()
require("neotest").setup({
adapters = {},
})
end,
}Important
Notice the adapters = {} above: this array starts empty, and we will fill it in one by one in the next section. The golden rule: every adapter you name in adapters must be registered in dependencies with per-filetype lazy-loading — otherwise, require("neotest-go") returns nil and Neotest errors on startup.
Now we complete the config with adapters for the languages most commonly used by backend and DevOps teams: Go, Python, and JavaScript/TypeScript. Notice that each adapter is added twice — once as a dependency (with ft lazy-loading), and once as a function in adapters:
{
"nvim-neotest/neotest",
event = "VeryLazy",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-neotest/nvim-nio",
"nvim-treesitter/nvim-treesitter",
{ "nvim-neotest/neotest-go", ft = "go" },
{ "nvim-neotest/neotest-python", ft = "python" },
{ "nvim-neotest/neotest-jest", ft = { "javascript", "typescript", "javascriptreact", "typescriptreact" } },
{ "marilari88/neotest-vitest", ft = { "javascript", "typescript", "javascriptreact", "typescriptreact" } },
},
keys = { /* keymaps yang sama seperti sebelumnya */ },
config = function()
require("neotest").setup({
adapters = {
require("neotest-go")(),
require("neotest-python")(),
require("neotest-jest")(),
require("neotest-vitest")(),
},
})
end,
}Each adapter has options that are useful in real situations. Let's take them apart one by one.
Go (neotest-go) — wraps go test. Options often used: experimental to enable new features (for example richer output), and dap for debugging tests with dlv:
require("neotest-go")({
experimental = true,
args = { "-count=1", "-timeout=60s" },
dap = {
dap_open = function() end,
},
})Python (neotest-python) — uses pytest by default. If the project uses unittest, you can choose its runner. The option most often needed is specifying which Python interpreter is used (especially in virtualenv environments):
require("neotest-python")({
python = "python3", -- atau path virtualenv
pytest_discover_instances = true,
pytest_args = { "-p", "no:cacheprovider", "--maxfail=1" },
dap = {
args = { "python3", "-m", "pytest", "-p", "no:cacheprovider" },
},
})Jest (neotest-jest) — wraps npx jest. The most critical configuration is jestCommand, cwd, and env — because in real JavaScript projects, jest is rarely on the global PATH:
require("neotest-jest")({
jestCommand = "npm test --",
env = { CI = true },
cwd = function(path)
-- Cari folder yang punya package.json / jest.config paling dekat
return vim.fn.getcwd()
end,
})Vitest (neotest-vitest) — wraps npx vitest run. This adapter supports watch mode and streaming output:
require("neotest-vitest")({
vitestCommand = "npx vitest run",
vitestConfigFile = "vitest.config.ts",
})Warning
If the project uses Vitest, avoid installing neotest-jest and neotest-vitest together without a filter — both adapters can claim the same *.test.ts files and cause tests to be run twice. The solution: separate them per project by determining which adapter is active based on file location, or simply install the adapter matching your project's stack. Do not let two adapters compete for the same file.
With the setup above, let's run a real workflow. Open a math_test.go file:
package main
import "testing"
func TestAdd(t *testing.T) {
if Add(2, 3) != 5 {
t.Fatal("expected 5")
}
}
func TestMultiply(t *testing.T) {
if Multiply(2, 3) != 6 {
t.Fatal("expected 6")
}
}Place your cursor inside TestAdd, then press <leader>tr (run nearest). Neotest will:
TestAdd.go test -run TestAdd -count=1 -timeout=60s ..auto_close = true — the panel closes by itself when the test passes).If the test fails, press <leader>to to see the detailed output, then ]n to jump to the next failed test, [n to go back. Fix it, press <leader>tl (run last) to rerun the same test without guessing again.
When you want to run all tests in one file, use <leader>tt. For the whole project (from the working directory), use <leader>tT. And <leader>tw enables watch mode — every time you save a file, the tests in that file are automatically rerun, like vitest watch or pytest --watch.
There are two ways to see results: the summary window and the output panel.
<leader>ts) shows a tree of tests with status per node — useful when running the whole suite and wanting to see the big picture: how many tests pass, how many fail, in which files.<leader>tO) shows the raw output from the runner — exactly what you would see in the terminal. This is where you read the actual stack traces and assertion messages.Besides keymaps, Neotest also provides the :Neotest command with the same sub-commands. The combination of both gives flexibility: keymaps for quick actions, commands for rarely used ones.
One of Neotest's most powerful features is running tests under the debugger. In episode 22 we already set up nvim-dap. Neotest uses it through the dap strategy — press <leader>td (or :Neotest run -s dap), and the test runs in debug mode:
nvim-dap-ui shows variables, call stack, and watch — exactly like debugging a test in a GUI IDE.-- Di dalam require("neotest").setup(...)
strategies = {
dap = {
presentation = "split", -- tampilkan UI debugger di split bawah
},
},Remember: the dap strategy is only useful if the language's adapter supports it (for example Go needs dlv). For Python, debugpy must be available. If the debugger is not found, Neotest will tell you through the output panel.
A summary of the keymaps you will use every day:
| Keymap | Function |
|---|---|
<leader>tr | Run the nearest test |
<leader>tt | Run the whole test file |
<leader>tT | Run all tests in the working directory |
<leader>tl | Rerun the last test |
<leader>tw | Toggle watch mode on the file |
<leader>ts | Toggle the summary window |
<leader>to | Show the last test output |
<leader>tO | Toggle the output panel |
<leader>tS | Stop the running test |
<leader>td | Run the test with a debugger (DAP) |
]n / [n | Jump to the next / previous failed test |
Adapter not installed but required. An attempt to call a nil value (global 'require') error or the adapter does not appear in the gutter — check that the adapter is in dependencies and the adapter function is called in adapters. This is the most common reason Neotest "does nothing".
Test discovery fails because the Treesitter parser is not installed. Neotest needs the Treesitter parser for the language to discover test positions. Open a file of that language, run :TSInstall go, :TSInstall python, or :TSInstall typescript, then try again.
Wrong working directory. Neotest runs test commands from the detected root. If your project is a monorepo (for example a Go workspace or pnpm workspace), tests can fail because they are run from the wrong directory. Adjust the cwd option on the adapter (like the Jest example above) or make sure Neovim is opened from the project root.
"No tests found" even though test files exist. Often happens when test files do not follow the adapter's conventions — for example a test_foo.py file outside the directory pytest recognizes, or a _test.go in a package that has no go.mod at the level being searched. Check the project structure and adapter configuration.
Two adapters claiming the same file (Jest vs Vitest). This produces double execution and confusing results. Make sure only one adapter is active per stack, or filter based on cwd/path.
Runner binary not in PATH. jest, vitest, or python3 must be callable from the Neovim environment. Verify with :!which jest / :!go version. If you use a Node version manager (nvm), make sure its PATH is loaded before Neovim runs — this also affects the LSPs launched by Mason.
| No | Item | Status |
|---|---|---|
| 1 | neotest + plenary + nvim-nio installed | ☐ |
| 2 | Treesitter parser for the project language installed | ☐ |
| 3 | Adapter for each language registered in dependencies & adapters | ☐ |
| 4 | No two adapters claiming the same filetype | ☐ |
| 5 | Run/summary/output keymaps defined | ☐ |
| 6 | Tests run successfully via <leader>tr on a real project | ☐ |
| 7 | (Optional) Debugging tests with DAP tested | ☐ |
In episode 26 we integrated testing directly into the Neovim workflow: installing neotest with its core + adapter architecture, configuring adapters for Go, Python, and JavaScript/TypeScript, and building a complete workflow — from running one nearest test, one file, the whole suite, watch mode, to debugging tests with nvim-dap. Now your Neovim is no longer just an editor, but a complete development environment: writing, running, and verifying code without ever switching applications.
The key is: the closer tests are to the typing flow, the more often tests are run — and the stronger your safety net. The <leader>tr flow after every change you write will become a reflex that saves you many times over.
In episode 27 — the last episode of the Learn Neovim series — we will assemble the entire journey from episode 0 to now into one complete production-grade config architecture: the modular init.lua structure, options, keymaps, autocmds, plugins, LSP, completion, formatting, right down to a daily-driver readiness checklist. This will be the finale that ties everything together. See you in the final episode!