Every change to a codebase risks breaking something that already worked. Clicking through an app or calling endpoints by hand after each change is slow, boring and easy to get wrong. Automated tests are code that runs your code with known inputs and checks the results, in seconds, every time you save or push.
Node.js includes everything you need. The node:test module provides a test runner with test, describe and it, hooks, mocking and coverage, and node:assert/strict provides the checks. You run every test file with a single command, node --test, with no packages to install. Popular alternatives such as Vitest and Jest offer the same ideas with extra features, so the skills transfer directly.
In this lesson you'll write unit tests for pure functions, test async code and errors, replace slow or unpredictable dependencies with mocks, control time with mock timers, and test an Express API from the outside by sending it real HTTP requests. Good tests make you faster, not slower: they let you refactor and upgrade packages with confidence.
Writing and running tests
// cart.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { total } from './cart.js';
describe('total', () => {
test('adds item prices', () => {
assert.equal(total([{ price: 2 }, { price: 3 }]), 5);
});
test('is 0 for an empty basket', () => {
assert.equal(total([]), 0);
});
});node --test # finds files like *.test.js and runs them
node --test --watch # re-runs when files change
node --test --experimental-test-coverageassert.equalcompares with===;assert.deepEqualcompares objects and arrays by content.assert.throws(fn, ErrorType)checks that a function throws;await assert.rejects(promise)does the same for async code.- Test functions can be
async: the runner waits for them to finish. before,after,beforeEachandafterEachset up and clean up shared state.
Mocks and testable design
Code that calls the network, the database or the clock directly is hard to test. Pass those dependencies in instead, and tests can supply fakes:
export function makeCheckout({ payments, clock }) {
return async (order) => {
const ok = await payments.charge(order.total);
return { ok, at: clock.now() };
};
}mock.fn(impl)creates a function that records every call:charge.mock.callCount()andcharge.mock.calls[0].arguments.mock.method(object, 'name')replaces a method on an existing object for one test.mock.timers.enable({ apis: ['setTimeout'] })lets tests move time forward instantly withmock.timers.tick(ms).
Testing an API
Export the Express app from one module and call listen in another. Tests can then start the app on port 0 (any unused port), send requests with fetch, and check status codes, headers and bodies. These integration tests check routing, middleware, validation and error handling together, exactly as clients experience them.
Running just the tests you need
As a test suite grows, you'll often want to run only part of it. The runner gives you several ways:
node --test cart.test.js # one file
node --test --test-name-pattern="clock" # tests whose names match
node --test --test-only # only tests marked { only: true }Inside a file, options on test change how individual tests run:
test('pays with a saved card', { skip: 'waiting on payments API' }, () => { /* ... */ });
test.todo('applies a discount code');
test('focus while debugging', { only: true }, () => { /* ... */ });skipreports the test as skipped, with your reason, instead of running it.test.todorecords a test you plan to write; it shows in the summary but doesn't fail the run.- In Node 22,
onlyhas no effect unless you also pass--test-only, so a forgottenonlywon't silently hide the rest of your suite in CI.
Each test function also receives a context argument, usually called t. Mocks created through it, such as t.mock.method(obj, 'name') or t.mock.timers.enable(...), are restored automatically when that test ends, so one test's fakes can't leak into the next.
Node.js Advanced Syntax
Testing essentials
import { test, describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert/strict';
test('name', () => { assert.equal(1 + 1, 2); });
test('async', async () => { assert.deepEqual(await load(), { id: 1 }); });
assert.throws(() => parse('x'), SyntaxError);
await assert.rejects(fetchUser(-1), /not found/);
const fn = mock.fn(() => 42); fn(); fn.mock.callCount(); // 1
mock.timers.enable({ apis: ['setTimeout'] }); mock.timers.tick(1000);
// node --test node --test --watchNode.js Advanced Summary
- node:test provides test, describe, it and hooks; run everything with node --test.
- node:assert/strict checks results: equal, deepEqual, throws and rejects.
- Pass dependencies in so tests can supply fakes; mock.fn records calls.
- Mock timers move time forward instantly for code that waits.
- Test APIs by starting the app on port 0 and calling it with fetch.
Node.js Advanced Examples
Each example shows the code, the output it produces and what each part does.
Checks with node:assert
import assert from 'node:assert/strict';
function applyDiscount(price, code) {
if (price < 0) throw new RangeError('Price must not be negative');
const rates = { SAVE10: 0.1, HALF: 0.5 };
return Math.round(price * (1 - (rates[code] ?? 0)) * 100) / 100;
}
const checks = [
['10% off', () => assert.equal(applyDiscount(40, 'SAVE10'), 36)],
['unknown code', () => assert.equal(applyDiscount(40, 'NOPE'), 40)],
['rounding', () => assert.equal(applyDiscount(19.99, 'HALF'), 9.99)],
['negative price', () => assert.throws(() => applyDiscount(-1), RangeError)],
['objects', () => assert.deepEqual({ a: [1, 2] }, { a: [1, 2] })],
['deliberate failure', () => assert.equal(applyDiscount(10, 'HALF'), 6)],
];
for (const [name, check] of checks) {
try {
check();
console.log(`pass ${name}`);
} catch (err) {
console.log(`FAIL ${name}: expected ${err.expected}, got ${err.actual}`);
}
}pass 10% off
pass unknown code
pass rounding
pass negative price
pass objects
FAIL deliberate failure: expected 6, got 5Line-by-line explanation
- Line 1The strict version of assert compares with ===.
- Line 1219.99 halved is 9.995, but floating point stores it as slightly less, so it rounds to 9.99. Tests catch surprises like this.
- Line 13assert.throws passes only if the function throws the given error type.
- Line 14deepEqual compares objects and arrays by their contents.
- Line 15A failing assertion throws an AssertionError with the expected and actual values.
A test file run with node --test
// price.test.mjs
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
const total = (items) => items.reduce((sum, i) => sum + i.price * (i.qty ?? 1), 0);
describe('total', () => {
test('adds prices and quantities', () => {
assert.equal(total([{ price: 2.5, qty: 2 }, { price: 1 }]), 6);
});
test('is 0 for an empty basket', () => {
assert.equal(total([]), 0);
});
test('async code is awaited', async () => {
const items = await Promise.resolve([{ price: 4 }]);
assert.equal(total(items), 4);
});
});node --test --test-reporter=tap price.test.mjs | grep -E "^# (tests|pass|fail)"# tests 3
# pass 3
# fail 0Line-by-line explanation
- Line 2test and describe come from the built-in node:test module.
- Line 7describe groups related tests under one name.
- Lines 8 to 10Each test has a name that reads as a sentence about the behaviour.
- Lines 14 to 17Async tests are awaited, so promises are checked properly.
Mocks and an API test
import express from 'express';
import assert from 'node:assert/strict';
import { mock } from 'node:test';
function createApp({ payments }) {
const app = express();
app.use(express.json());
app.post('/checkout', async (req, res) => {
if (!(req.body.total > 0)) return res.status(400).json({ error: 'Invalid total' });
const ok = await payments.charge(req.body.total);
res.status(ok ? 201 : 402).json({ paid: ok });
});
return app;
}
const charge = mock.fn(async (amount) => amount < 500);
const server = createApp({ payments: { charge } }).listen(0, async () => {
const url = `http://localhost:${server.address().port}/checkout`;
const post = (body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
let r = await post({ total: 59.9 });
assert.equal(r.status, 201);
r = await post({ total: 900 });
assert.equal(r.status, 402);
r = await post({ total: -1 });
assert.equal(r.status, 400);
assert.equal(charge.mock.callCount(), 2);
assert.deepEqual(charge.mock.calls.map((c) => c.arguments[0]), [59.9, 900]);
console.log('All API checks passed');
server.close();
});All API checks passedLine-by-line explanation
- Line 5The app receives its payment service as a parameter, so tests can pass in a fake.
- Line 16mock.fn creates a fake charge function that records every call.
- Line 17The app starts on any unused port for the test.
- Lines 21 to 26Real HTTP requests check status codes for success, a declined card and invalid input.
- Line 28The invalid request never reached the payment service.
- Line 29The recorded arguments show exactly what was charged.
Common Node.js Advanced Mistakes
Testing against real external services
test('charges the card', async () => {
await stripe.charges.create({ amount: 500 }); // slow, costs money, fails when offline
});test('charges the card', async () => {
const payments = { charge: mock.fn(async () => true) };
await checkout({ payments })({ total: 5 });
assert.equal(payments.charge.mock.callCount(), 1);
});Forgetting to await async assertions
test('rejects unknown users', () => {
assert.rejects(loadUser(-1)); // not awaited: the test finishes before the check
});test('rejects unknown users', async () => {
await assert.rejects(loadUser(-1));
});Comparing objects with equal
assert.equal(getUser(), { id: 1 }); // fails: two different objects are never ===assert.deepEqual(getUser(), { id: 1 });Node.js Advanced in Real Projects
Testing in Node teams
Teams run node --test or Vitest on every pull request in GitHub Actions and block merges when tests fail. Unit tests cover business rules and validation, integration tests start the app against a temporary database, often in Docker, and a few end-to-end tests drive a real browser with Playwright. Coverage reports highlight untested code, and watch mode gives instant feedback while developing.
Node.js Advanced FAQ
Should I use node:test, Jest or Vitest?
node:test is built in, needs no installation and runs plain JavaScript quickly, which makes it a good default for back-end code and libraries. Jest and Vitest add extras such as snapshot testing, a richer watch mode and easier handling of TypeScript and front-end code; Vitest is the natural choice in projects that already use Vite. The test-writing ideas are the same in all three.
What is the difference between unit tests and integration tests?
A unit test checks one small piece, such as a function that calculates a basket total, with its dependencies replaced by fakes. An integration test checks several parts working together, such as an HTTP request travelling through routing, validation and a test database. Unit tests are fast and pinpoint failures; integration tests catch problems in the joins between parts.
How do I test code that uses the current date or time?
Either pass a clock into your code so tests can supply a fixed one, or use the runner's fake timers: t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-01-01') }) makes new Date() and Date.now() return that moment, and t.mock.timers.tick(ms) moves it forward. In Node 22 the mock timers API is still marked experimental and prints a warning.
How much test coverage should I aim for?
There's no magic number. Coverage shows which lines your tests ran, not whether they checked the right things, so 100% can still miss bugs. Aim to cover the important behaviour, edge cases and error paths thoroughly, and use the coverage report (node --test --experimental-test-coverage) to find untested areas rather than as a target.
Why does my Node.js test pass even though the assertion should fail?
Usually an async check isn't being awaited: the test function returns before the promise settles, so the failure happens after the runner has recorded a pass, or not at all. Make the test async and await the code under test and any assert.rejects call. Also check that the test file actually ran, since files that don't match the runner's naming patterns are skipped.
Node.js Advanced Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the check that compares two arrays by content.
assert.([1, 2], [1, 2]);
Which command runs Node's built-in test runner?
How do you check that an async function rejects?
What does fn.mock.callCount() return here?
const fn = mock.fn();
fn(1);
fn(2);
fn(3);
Why pass dependencies such as a payment service into a function instead of importing them directly?
Node.js Advanced Quiz
5 questions to check what you learned.
Which module provides test, describe and it?
- node:assert
- node:spec
- node:check
- node:test
What does assert.equal use to compare in node:assert/strict?
- ===
- ==
- Object.is only for numbers
- JSON.stringify
How can a test move time forward without waiting?
- Enable mock timers and call mock.timers.tick(ms)
- await sleep
- Change the system clock
- It can't
How should an API test start the server?
- On port 80
- By running npm start in another terminal
- It doesn't need a server
- On port 0, so the system picks any unused port
What should good tests focus on?
- Private helper functions
- Observable behaviour: inputs, outputs and errors
- Line-by-line implementation
- Only the happy path