{"id":506,"date":"2026-07-26T05:03:45","date_gmt":"2026-07-26T05:03:45","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/node-built-in-test-runner-deleted-jest\/"},"modified":"2026-07-26T05:03:45","modified_gmt":"2026-07-26T05:03:45","slug":"node-built-in-test-runner-deleted-jest","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/node-built-in-test-runner-deleted-jest\/","title":{"rendered":"I deleted Jest for Node&#8217;s built-in test runner"},"content":{"rendered":"<p>Okay, this is going to sound petty, but I deleted Jest from a side project last month mostly because I was tired of its config. Not because it was slow, not because it was broken. I just didn&rsquo;t want to think about <code>transform<\/code> and <code>moduleNameMapper<\/code> and which Babel plugin I needed this week. I swapped in Node&rsquo;s built-in test runner to see how far it&rsquo;d get me, and I haven&rsquo;t gone back on that project since.<\/p>\n<p>Short version: if you&rsquo;re on a recent Node and your tests don&rsquo;t need a browser, the node test runner that ships with the runtime might already be enough. No install, no config file, no plugin archaeology. Here&rsquo;s what I found once I actually used it in anger.<\/p>\n<p>I&rsquo;ll caveat this up front: I&rsquo;m not a testing purist, and I don&rsquo;t think the tool you pick matters as much as the fact that you write the tests at all. But friction is real, and every dependency in a test setup is one more thing that breaks on a Tuesday for reasons unrelated to your code. So a runner with nothing to break got my attention.<\/p>\n<h2 id=\"what-built-in-actually-buys-you\">What &ldquo;built in&rdquo; actually buys you<\/h2>\n<p>The thing that clicked for me is that there&rsquo;s nothing to add. The test runner lives inside Node itself, under the <code>node:test<\/code> module, and it&rsquo;s been marked stable since Node 20. That means zero dependencies in your <code>package.json<\/code> for the test layer, millisecond-ish startup because there&rsquo;s no bundler warming up, V8-native coverage, built-in mocking, and a watch mode. The official <a href=\"https:\/\/nodejs.org\/api\/test.html\" rel=\"nofollow noopener\" target=\"_blank\">Node.js test runner docs<\/a> lay out the full surface, and it&rsquo;s smaller and calmer than I expected.<\/p>\n<p>The old way, a fairly minimal Jest setup, looked like this before I ripped it out:<\/p>\n<pre><code class=\"language-json\">\/\/ package.json \u2014 the Jest era\n{\n  &quot;scripts&quot;: { &quot;test&quot;: &quot;jest&quot; },\n  &quot;devDependencies&quot;: {\n    &quot;jest&quot;: &quot;^29.0.0&quot;,\n    &quot;ts-jest&quot;: &quot;^29.0.0&quot;,\n    &quot;@types\/jest&quot;: &quot;^29.0.0&quot;\n  },\n  &quot;jest&quot;: {\n    &quot;preset&quot;: &quot;ts-jest&quot;,\n    &quot;testEnvironment&quot;: &quot;node&quot;,\n    &quot;moduleNameMapper&quot;: { &quot;^@\/(.*)$&quot;: &quot;&lt;rootDir&gt;\/src\/$1&quot; }\n  }\n}\n<\/code><\/pre>\n<p>Three dev dependencies and a config block, just to run a function and check its output. The new way is a script and nothing else:<\/p>\n<pre><code class=\"language-json\">\/\/ package.json \u2014 the node:test era\n{\n  &quot;scripts&quot;: { &quot;test&quot;: &quot;node --test&quot; }\n}\n<\/code><\/pre>\n<p>That&rsquo;s the whole thing. <code>node --test<\/code> finds your test files, runs them, and reports. I genuinely didn&rsquo;t believe it the first time.<\/p>\n<h2 id=\"writing-an-actual-test\">Writing an actual test<\/h2>\n<p>The API will feel familiar if you&rsquo;ve used more or less any runner in the last decade. You get <code>test<\/code>, you get <code>describe<\/code> and <code>it<\/code>, and assertions come from Node&rsquo;s own <code>assert<\/code> module. Here&rsquo;s the before, in Jest:<\/p>\n<pre><code class=\"language-js\">\/\/ sum.test.js \u2014 Jest\nconst { sum } = require(&quot;.\/sum&quot;);\n\ndescribe(&quot;sum&quot;, () =&gt; {\n  it(&quot;adds two numbers&quot;, () =&gt; {\n    expect(sum(2, 3)).toBe(5);\n  });\n});\n<\/code><\/pre>\n<p>And the after, using only what Node hands you:<\/p>\n<pre><code class=\"language-js\">\/\/ sum.test.js \u2014 node:test\nimport { test, describe, it } from &quot;node:test&quot;;\nimport assert from &quot;node:assert\/strict&quot;;\nimport { sum } from &quot;.\/sum.js&quot;;\n\ndescribe(&quot;sum&quot;, () =&gt; {\n  it(&quot;adds two numbers&quot;, () =&gt; {\n    assert.equal(sum(2, 3), 5);\n  });\n});\n<\/code><\/pre>\n<p>The difference that matters isn&rsquo;t the assertion style. It&rsquo;s that the second file imports from <code>node:test<\/code> and <code>node:assert<\/code>, which are already on your machine. Nothing resolved from <code>node_modules<\/code>. If you&rsquo;ve ever spent an evening debugging why a test runner couldn&rsquo;t find a module that clearly exists, you&rsquo;ll understand why this feels like a small weight lifting.<\/p>\n<p>One habit worth building: import assertions from <code>node:assert\/strict<\/code> rather than plain <code>node:assert<\/code>. The strict variant makes <code>equal<\/code> behave like <code>===<\/code> instead of the loose <code>==<\/code>, and I&rsquo;ve been bitten by the loose version comparing <code>2<\/code> and <code>\"2\"<\/code> as equal. The full set of matchers lives in the <a href=\"https:\/\/nodejs.org\/api\/assert.html\" rel=\"nofollow noopener\" target=\"_blank\">Node.js assert docs<\/a>, and it covers more than you&rsquo;d guess: deep equality, rejects, throws, the usual suspects. I rarely need more than five of them.<\/p>\n<h2 id=\"mocking-without-pulling-in-a-library\">Mocking without pulling in a library<\/h2>\n<p>This was the part I assumed I&rsquo;d miss, and the reason I figured I&rsquo;d crawl back to Jest. I didn&rsquo;t. The runner ships with a <code>mock<\/code> object that does spies, method replacement, and timer control, and it cleans up after itself when the test finishes so you&rsquo;re not leaking mocked state between cases.<\/p>\n<pre><code class=\"language-js\">import { test } from &quot;node:test&quot;;\nimport assert from &quot;node:assert\/strict&quot;;\n\ntest(&quot;calls the notifier once&quot;, (t) =&gt; {\n  const notifier = { send: () =&gt; {} };\n  t.mock.method(notifier, &quot;send&quot;);\n\n  doTheThing(notifier);\n\n  assert.equal(notifier.send.mock.callCount(), 1);\n});\n<\/code><\/pre>\n<p>The <code>t.mock.method<\/code> call replaces <code>send<\/code> with a spy for the length of that test and restores it automatically. No <code>beforeEach<\/code> cleanup, no <code>jest.restoreAllMocks()<\/code> you&rsquo;ll forget to write. There&rsquo;s a good practical walkthrough of the mocking surface in this <a href=\"https:\/\/betterstack.com\/community\/guides\/testing\/nodejs-test-runner\/\" rel=\"nofollow noopener\" target=\"_blank\">Better Stack guide to the Node test runner<\/a> if you want more than the toy example.<\/p>\n<h2 id=\"coverage-and-typescript-the-two-things-i-actually-checked\">Coverage and TypeScript, the two things I actually checked<\/h2>\n<p>Coverage used to mean wiring up a separate tool. Now it&rsquo;s a flag:<\/p>\n<pre><code class=\"language-bash\">node --test --experimental-test-coverage\n<\/code><\/pre>\n<p>It uses V8&rsquo;s native coverage under the hood, so there&rsquo;s no instrumentation step slowing your suite down. The report it prints is plain, but it&rsquo;s honest, and for a side project it&rsquo;s all I need.<\/p>\n<p>TypeScript surprised me most. On recent Node, type stripping is on by default for erasable TypeScript syntax, which landed around Node 22.18. In practice that means I can point <code>node --test<\/code> at <code>.ts<\/code> files and it runs them without ts-jest, without a build step, without a <code>tsconfig<\/code> fight. There are edge cases, mostly around enums and other non-erasable features, but for the plain &ldquo;types as annotations&rdquo; code I write most of the time, it just works.<\/p>\n<h2 id=\"getting-it-to-behave-in-ci\">Getting it to behave in CI<\/h2>\n<p>The one place the defaults tripped me up was continuous integration. Run <code>node --test<\/code> in a pipeline and the human-readable output is fine to read, but most CI dashboards want something machine-parseable. The runner has reporters built in for exactly this, so you don&rsquo;t reach for a plugin:<\/p>\n<pre><code class=\"language-bash\"># Pretty output locally, TAP for the CI to parse.\nnode --test --test-reporter=spec        # local, readable\nnode --test --test-reporter=tap         # CI, parseable\n<\/code><\/pre>\n<p>You can even run two reporters at once, sending the pretty version to your terminal and the TAP version to a file the CI picks up. I had this working in about five minutes, which is roughly four minutes and thirty seconds faster than the last time I configured a Jest reporter.<\/p>\n<p>The other CI nicety: exit codes are sane. A failing test exits non-zero, a passing run exits zero, and there&rsquo;s no wrapper script translating between formats. My pipeline config for the test step is now a single line, and I keep waiting for the catch. Haven&rsquo;t found one yet.<\/p>\n<h2 id=\"when-i-still-dont-use-it\">When I still don&rsquo;t use it<\/h2>\n<p>I want to be fair, because this isn&rsquo;t a clean win for every project. If your tests touch the DOM or need a real browser, the built-in runner isn&rsquo;t your tool, and reaching for something browser-aware is the right call. When a project already leans hard on Vite, I keep using Vitest, since it runs tests through the same transform pipeline as the dev server and that consistency is worth more than shedding a dependency. I got into that tradeoff in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/vitest-vs-jest-2026-what-i-actually-reach-for\/\" rel=\"noopener\">Vitest versus Jest<\/a>, and none of that changed.<\/p>\n<p>The built-in runner shines for backend code, libraries, and CLI tools: the stuff that runs in Node anyway. It pairs especially well with projects already trimming their dependency count, which is the same instinct that pushed me toward alternative runtimes in my <a href=\"https:\/\/abrarqasim.com\/blog\/bun-runtime-in-production-a-year-of-replacing-node\/\" rel=\"noopener\">year of running Bun in production<\/a>. Fewer moving parts, fewer things to patch.<\/p>\n<p>If you want to see the kind of small, dependency-light tooling I build around this, some of it lives on my <a href=\"https:\/\/abrarqasim.com\/about\" rel=\"noopener\">portfolio<\/a>.<\/p>\n<h2 id=\"try-this-on-one-file-this-week\">Try this on one file this week<\/h2>\n<p>Don&rsquo;t migrate a whole suite. Pick one test file in a Node-only project, rename your imports to pull <code>test<\/code> and <code>assert<\/code> from <code>node:test<\/code> and <code>node:assert\/strict<\/code>, and run it with <code>node --test<\/code>. Give it fifteen minutes. If you&rsquo;re like me, the thing you&rsquo;ll notice isn&rsquo;t speed. It&rsquo;s the quiet: no config file staring at you, nothing to keep updated, one less corner of the project that can rot. That quiet turned out to be the feature I actually wanted.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The node test runner now ships inside the runtime: zero dependencies, built-in mocking, coverage, even TypeScript. Where I dropped Jest for it, and where I didn&#8217;t.<\/p>\n","protected":false},"author":2,"featured_media":505,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The node test runner now ships inside the runtime: zero dependencies, built-in mocking, coverage, even TypeScript. Where I dropped Jest for it, and where I didn't.","rank_math_focus_keyword":"node test runner","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[165,197],"tags":[578,199,576,577,379,201,63],"class_list":["post-506","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","category-testing","tag-javascript-testing","tag-jest","tag-node-test-runner","tag-node-test","tag-node-js","tag-tooling","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/506","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=506"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/506\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/505"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=506"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=506"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=506"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}