Neovim Neotest adapter for node test runner
screenrecording-2025-12-31_14-03-12.mp4
This isn't for running jest or vitest tests, but node built-in test runner tests (but it will play along nicely if you have neotest-vitest or neotest-jest).
Requires node 18+, for typescript support you need node v22.18.0+ (22.6.0 if you
add --experimental-strip-types to args)
- run node test runner tests and suites from your nvim;
- test results streaming - your results will appear in the UI one by one as they're processed by the node;
- detection of imports coming from
node:test, so the plugin plays along nicely with your existing vitest/jest/bun adapter setup and doesn't trigger on foreign tests; - DAP debugger connection;
- Typescript and JS test suites;
- environment and args parameters (e.g. for setting up global setup/teardown)
- output capture for you to see your console.logs and stuff run in a test;
- dynamic/parametrezied tests, e.g. this won't work:
for (const arg of ["foo", "bar"]) { it(`smarty-pants ${arg} test`, (t) => { t.assert(false, "this won't work"); }); } - template strings and any kind of string manipulation in test/suite names, tests must be statically analyzable
- import renames of it/test/describe/suite -- why would you do that?..
In your neotest setup (e.g. lua/plugins/neotest.lua)
return {
{
"nvim-neotest/neotest",
dependencies = {
"religios1/neotest-node"
},
opts = {
-- notice if you also mixed vitest/jest/bun tests in your project, this
-- adapter most likely must come last, otherwise other adapters will
-- intercept the test file
adapters = { "neotest-node" }
},
},
}Default values are provided for the reference, you don't need to copy them.
return {
{
"nvim-neotest/neotest",
dependencies = {
"religios1/neotest-node"
},
opts = {
adapters = {
["neotest-node"] = {
---Additional environment options
---@type table<string, string> | fun(): table<string, string>
env = function()
return {}
end,
---Test command (`node --test`) current working dir
---@type string | fun(position_path: string): string?
cwd = function(position_path)
local lib = require("neotest.lib")
return lib.files.match_root_pattern("package.json")(position_path)
end,
---Filtering out dirs from tests detection
---@type fun(name: string, rel_path: string, root: string): boolean
filter_dir = function (name, rel_path, root)
return name ~= "node_modules"
end,
---Is file with given path a node test runner test file?
---@type fun(file_path: string): boolean
is_test_file = function (file_path)
if file_path:match(".*%.test%.[cm]?[tj]sx?$") == nil
and file_path:match(".*%.spec%.[cm]?[tj]sx?$") == nil then
return false
end
local util = require("neotest-node.util")
return util.has_node_test_imports(file_path)
end,
---Test command additional arguments
---@type string[] | fun(args: neotest.RunArgs): string[]
args = {},
}
},
},
},
}Plugin is running node --test --test-reporter tap for the selected file/
test pattern, and then parses node test runner TAP
output to report results and capture potential error messages + error lines.
As I can imagine you also have vitest/jest/bun or whatever else in your adapters, we must differentiate between node tests vs any other solution. jest and vitest adapters check presence of their corresponding testrunner in dependencies, while bun can check for bun lockfile.
We don't have this option, as node test runner won't be present in deps -- it comes out of the box.
So instead of inspecting package.json (which isn't required for this adapter),
if the file has the correct extension (e.g. foo.test.ts or bar.spec.js)
we're reading the first 2000 chars from the file and trying to find an import
from node:test with a regex (be that CJS or ESM import).
We're using regex instead of treesitter, to avoid extra overhead of parsing every test files just to determine if we should do anything with a file.
This detection must happen in is_test_file adapter function -- neotest passes
test execution to the first adapter matched by its is_test_file function.
Iteration over adapters is performed
with pairs() call over object, so order of adapters matter but not guaranteed.
To play it safe, you actually need to add verification function for all of you ts/js adapter, such as notest-jest, neotest-vite, etc.
require("neotest-node.util").has_node_test_imports(file_path)Example neotest opts:
{
"nvim-neotest/neotest",
-- dependencies and other blocks goes here, omitted for brevity
opts = {
["neotest-jest"] = {
isTestFile = function(file_path)
if not file_path then
return false
end
if require("neotest-node/util").has_node_test_imports(file_path) then
return false
end
return vim.fn.fnamemodify(file_path, ":e:e"):match("test%.[jt]sx?$") ~= nil
end,
},
"neotest-node",
}
}This introduces some overhead, which I consider negliable for my use-cases, but
if you want to disable this functionality completely (in cases you don't have
any other js/ts adapters besides this one) you can pass your custom
is_test_file in the adapter options in your config, e.g.:
-- opts in config:
{
is_test_file = function (file_path)
return file_path:match(".*%.test%.[cm]?[tj]sx?$") ~= nil
end
}For running tests you need a neovim setup and node 20+ available in your path.
To run the whole test suite:
./scripts/test.shYou can launch a specific unit-test by:
./scripts/test.sh tests/yaml-diagnostics-parser_spec.luaOn the initial launch script will retrieve its deps by cloning the corresponding
github repos into .testsdep folder.
neotest-node is MIT licensed.