Node.js, part 17: the built-in test runner and watch mode
Part 17from the Node.js series · 24 parts in all
Jest, Mocha, Vitest, ts-node, nodemon, chokidar — an ordinary Node project used to begin with a page of devDependencies that existed to run and re-run its own code. Node 18 made the test runner and the watcher built-ins. Part 17 is what the built-in runner does well, and the line where a framework still wins.
A test file, no framework
// test/pricing.test.js
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { total } = require('../src/pricing');
describe('total', () => {
test('adds tax and shipping', () => {
assert.equal(total({ items: 100, tax: 8, shipping: 5 }), 113);
});
test('rejects a negative quantity', async () => {
await assert.rejects(() => total({ items: -1 }), /quantity/);
});
});
node --test # finds test/**/*.test.js and friends
node --test --test-only # run only tests marked with test.only
node --watch server.js # restart on change, built in
node --watch --test # watch mode for tests
That is the whole setup: no config file, no transform, no globals injected into the global
scope. Assertions are node:assert, which gained a strict mode — worth using, since
assert.equal is loose and assert.strictEqual is not the same thing.
What it does better than a framework
- No transpilation step, so a stack trace points at your file. A failing assertion names the line you wrote, not a source-mapped approximation.
- Real subprocess isolation.
--testruns each file in its own process, so a test that leaks global state cannot poison the next file. Jest needs--runInBandand discipline for the same guarantee. - Coverage is built in too (
--experimental-test-coverage), which is the work of thenycdependency.
Where a framework still wins
Mocking, rich snapshot testing, fake timers, and a browser-like DOM. The built-in runner has a mocking API now, but if you are testing React components you want Vitest or Jest and the DOM environment with them. The sensible split: use the built-in runner for library and service logic, keep a framework for the parts that genuinely need one — and note that you no longer install a test runner for a small package at all. Next: pinning the package manager itself.