Concept
The beginner framing: testing a Node.js application has traditionally meant installing a framework, Jest, Mocha, Vitest, before writing a single test. That's no longer strictly true: Node ships a real, capable test runner built in.
The precise mental model: node:test provides describe/it (or test) for structuring tests, integrates with node:assert for assertions, and, the part most people don't expect, has a genuinely capable built-in mocking API. Confirmed directly against this app's installed Node v23.11.0: node:test, describe, it, and the mock object (mock.fn, mock.method, mock.timers) are all present and functioning, with no experimental flag required for any of them.
// server.test.js
const { test } = require("node:test");
const assert = require("node:assert");
test("adds two numbers", () => {
assert.strictEqual(2 + 2, 4);
});node --test server.test.jsdescribe/it structuring, same shape as Jest/Mocha
const { describe, it } = require("node:test");
const assert = require("node:assert");
describe("Router", () => {
it("matches an exact path", () => {
const router = createRouter();
router.add("GET", "/users", () => "ok");
assert.strictEqual(router.match("GET", "/users"), "ok");
});
it("returns 404 for unknown paths", ()
If the test structuring syntax looks immediately familiar coming from Jest or Mocha, that's deliberate, node:test was designed to feel unsurprising to anyone who's used either.
Built-in mocking: mock.fn, mock.method, and timers
const { test, mock } = require("node:test");
const assert = require("node:assert");
test("calls the callback exactly once", () => {
const callback = mock.fn();
[1, 2, 3].forEach(callback);
// wait, that calls it 3 times; a real example:
const onlyOnce = mock.fn();
onlyOnce();
assert.strictEqual(onlyOnce.mock.calls.length, 1);
});
test("mocks a method on a real object"
mock.fn() creates a bare mock function with call tracking (.mock.calls, .mock.callCount()); mock.method(obj, methodName, implementation) replaces a real object's method temporarily, auto-restoring it after the test. Both confirmed working in this runtime with zero external dependencies, no jest.fn()/sinon install required for this class of test double.
test("fires after the configured delay", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const callback = mock.fn();
setTimeout(callback, 1000);
t.mock.timers.tick(1000); // advance FAKE time, no real 1-second wait
assert.strictEqual(callback.mock.callCount(), 1);
});mock.timers fakes setTimeout/setInterval so time-dependent code can be tested instantly instead of the test suite actually waiting in real time, the same capability Jest's fake timers provide, built in.
Coverage: still experimental, the one honest caveat
node --test --experimental-test-coverageConfirmed directly against this runtime: coverage reporting requires --experimental-test-coverage, unlike describe/it/mock, this piece is NOT yet stable. Worth knowing before assuming built-in coverage is production-ready on the same footing as the rest of the runner.
When a framework is still warranted
node:test covers unit and integration testing well, but it deliberately doesn't try to be everything, there's no built-in snapshot testing, no built-in DOM/component testing utilities, and its ecosystem of assertion-style plugins and reporters is thinner than Jest's or Vitest's mature plugin ecosystems. For a frontend-heavy project already using component snapshot testing, or a team with deep existing Jest tooling investment, switching purely to save a dependency isn't obviously worth it. For a Node.js backend service or library with straightforward unit/integration test needs, node:test is a legitimate, dependency-free default.
Try It
Predict the outcome before checking the solution.
test("mocked method auto-restores after the test", (t) => {
const db = { query: () => "real" };
t.mock.method(db, "query", () => "mocked");
assert.strictEqual(db.query(), "mocked");
});
test("a later, separate test", () => {
const db = { query: () => "real" };
console.log(db.query()); // ?
});Does the second test see the mocked or the real implementation?
Solution
The second test sees "real", a completely fresh db object was created inside it, unrelated to the first test's mocked object. But even setting that aside: t.mock.method() (using the test context t, not the standalone mock import) automatically restores the original method once the test it belongs to completes, specifically so mocking doesn't leak between tests. This automatic cleanup is one of the meaningful advantages of using the per-test t.mock context over the global mock import for anything that needs to be restored.
Implement It Yourself
Build a minimal assertion helper on top of node:assert, to see how thin the abstraction actually is:
const assert = require("node:assert");
function expect(actual) {
return {
toBe(expected) {
assert.strictEqual(actual, expected);
},
toThrow(expectedMessage) {
assert.throws(actual, { message: expectedMessage });
},
toBeGreaterThan(expected) {
assert.ok(actual > expected, `expected ${actual} to be greater than ${expected}`);
},
};
}
// usage, Jest-flavored syntax on top of node:assert underneath:
const { test
This demonstrates that Jest-style expect(...).toBe(...) syntax isn't magic, it's a thin wrapper over more primitive assertion calls, which is exactly what node:assert already provides natively.
Under the Hood
--test --watch combines the built-in test runner with the same --watch flag covered as a nodemon alternative, confirmed stable in Runtime Overview, re-running affected tests automatically on file change. And profiling a slow test suite (or slow production code the tests exercise) uses the same --cpu-prof workflow from Performance & Profiling, which gives node:test only a brief mention, this topic is where that gets its full depth.
Common Mistakes
1. Assuming built-in coverage is stable
node --test --coverage # ❌ not the actual flag, this is experimental
node --test --experimental-test-coverage # ✅ correct, and honestly still experimentalTreating coverage reporting as production-hardened on the same level as describe/it/mock overstates its current maturity.
2. Using the global mock import when a mock needs auto-restoration
const { mock } = require("node:test");
test("...", () => {
mock.method(obj, "method", impl); // ❌ does NOT auto-restore after this test
});Prefer the test-context t.mock.method(...) (accessing t from the test callback's parameter) when the mock should be automatically cleaned up after its specific test, the global import requires manual .mock.restoreAll() bookkeeping instead.
3. Reaching for a full framework before checking if node:test covers the need
npm install jest # for a straightforward Node.js backend service's unit testsFor plain unit/integration testing of backend logic with no snapshot/DOM-testing requirement, this is often an unnecessary dependency, node:test frequently covers the actual need without it.
Best Practices
- Default to
node:testfor new Node.js backend/library projects with straightforward unit/integration testing needs, it's dependency-free and stable for the core features. - Use
t.mock(the per-test context) over the globalmockimport when a mock should auto-restore after its test. - Flag coverage reporting as experimental in any team documentation or CI setup relying on it, it's the one piece of
node:testgenuinely behind the rest in stability. - Reach for a full framework deliberately, when snapshot testing, extensive existing plugin/reporter tooling, or component/DOM testing utilities are actually needed, not by default habit.
Performance Tips
node --test --watchre-running only affected tests on file change (rather than the full suite every time) keeps the local feedback loop fast, the same principle that makes--watchvaluable for development generally.mock.timersavoiding real elapsed time for timer-dependent tests isn't just convenient, it's a genuine test-suite speed win, since a suite with dozens of real 1-secondsetTimeoutwaits adds up fast; faking them collapses that to near-zero real time.
