Testing with node:test
Every time you change code, something else might break. Clicking through your app by hand after each change is slow, and you will forget a case. Automated tests are small programs that call your code and check the results. Run them in seconds after every change, and they tell you exactly what broke.
Node.js has a complete test runner built in: the node:test module and the node --test command. You do not need to install Jest or Mocha to start. In this lesson you will test plain functions, read a failing test report, test an Express API over HTTP, replace functions and timers with mocks, and measure coverage.
Project layout
Keep your code and your tests in separate folders, and name test files so the runner finds them:
bookshop/
package.json "type": "module", "scripts": { "test": "node --test" }
src/
cart.js
app.js exports the Express app (no listen)
server.js imports app and calls app.listen()
test/
cart.test.js
api.test.jsWith no file names, node --test searches the project for files such as *.test.js, *-test.js, *_test.js and any .js file inside a test folder, and runs each file in its own process.
Your first tests
Here is the code to test, a shopping cart with GST:
// src/cart.js
export const GST_RATE = 0.18;
export function lineTotal(price, qty) {
if (!Number.isInteger(qty) || qty < 1) throw new RangeError("qty must be a whole number above 0");
return price * qty;
}
export function cartTotal(items, couponPercent = 0) {
const subtotal = items.reduce((sum, i) => sum + lineTotal(i.price, i.qty), 0);
const discounted = subtotal * (1 - couponPercent / 100);
return Math.round(discounted * (1 + GST_RATE));
}And the tests:
// test/cart.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { lineTotal, cartTotal } from "../src/cart.js";
describe("lineTotal", () => {
it("multiplies price by quantity", () => {
assert.equal(lineTotal(250, 3), 750);
});
it("rejects a quantity of zero", () => {
assert.throws(() => lineTotal(250, 0), RangeError);
});
});
describe("cartTotal", () => {
const items = [
{ name: "Notebook", price: 60, qty: 5 },
{ name: "Geometry box", price: 270, qty: 1 },
];
it("adds 18% GST", () => {
assert.equal(cartTotal(items), 673);
});
it("applies a coupon before GST", () => {
assert.equal(cartTotal(items, 10), 605);
});
it("returns 0 for an empty cart", () => {
assert.equal(cartTotal([]), 0);
});
});npm test▶ lineTotal ✔ multiplies price by quantity (3.2213ms) ✔ rejects a quantity of zero (1.0979ms) ✔ lineTotal (6.8931ms) ▶ cartTotal ✔ adds 18% GST (0.9339ms) ✔ applies a coupon before GST (0.3185ms) ✔ returns 0 for an empty cart (0.3217ms) ✔ cartTotal (3.2939ms) ℹ tests 5 ℹ suites 2 ℹ pass 5 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 467.396
describe()groups related tests into a suite;it()is one test. You can also usetest(), which is the same asit().- A test passes if its function finishes without throwing, and fails if it throws.
node:assert/strictgives you checks that throw when something is wrong:assert.equal(actual, expected)uses===,assert.deepEqual()compares objects and arrays by their contents,assert.throws()expects a function to throw,assert.rejects()expects a promise to reject, andassert.ok(value)expects a truthy value.
Good tests check normal cases, edge cases (empty cart) and error cases (quantity zero).
Reading a failure
Suppose the coupon test expected 600 by mistake. The runner marks it with a cross and explains:
▶ lineTotal
✔ multiplies price by quantity (5.4873ms)
✔ rejects a quantity of zero (1.2715ms)
✔ lineTotal (10.9158ms)
▶ cartTotal
✔ adds 18% GST (0.8586ms)
✖ applies a coupon before GST (2.4133ms)
✔ returns 0 for an empty cart (0.3657ms)
✖ cartTotal (4.3672ms)
ℹ tests 5
ℹ suites 2
ℹ pass 4
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 338.3054
✖ failing tests:
test at test\cart.test.js:25:3
✖ applies a coupon before GST (2.4133ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
605 !== 600
at TestContext.<anonymous> (file:///C:/node-course/bookshop/test/cart.test.js:26:12)
...You see the file and line of the failing test, the actual value (605) and the expected value (600). Now decide: is the code wrong or the test? Here the test was wrong: (300 + 270) × 0.9 × 1.18 = 605.34, which rounds to 605. The command also exits with code 1, so automated systems know the tests failed.
Testing an Express API
To test routes, split the app from the server: app.js exports the configured app, and only server.js calls listen(). A test can then start the app on a random free port (port 0) and call it with fetch():
// test/api.test.js
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { app } from "../src/app.js";
describe("books API", () => {
let server;
let base;
before(async () => {
server = app.listen(0); // 0 = any free port
await new Promise((resolve) => server.once("listening", resolve));
base = `http://localhost:${server.address().port}/api/books`;
});
after(() => server.close());
it("lists books", async () => {
const res = await fetch(base);
assert.equal(res.status, 200);
const books = await res.json();
assert.deepEqual(books[0], { id: 1, title: "Godan", price: 180 });
});
it("creates a book", async () => {
const res = await fetch(base, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Gaban", price: 150 }),
});
assert.equal(res.status, 201);
assert.equal((await res.json()).title, "Gaban");
});
it("rejects a book without a price", async () => {
const res = await fetch(base, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Nirmala" }),
});
assert.equal(res.status, 400);
});
});▶ books API ✔ lists books (350.1724ms) ✔ creates a book (130.1469ms) ✔ rejects a book without a price (18.9347ms) ✔ books API (539.3618ms)
before() runs once before the tests in the suite and after() once after them; beforeEach() and afterEach() run around every test. Async test functions work naturally: the runner waits for the promise. Tests that touch a database should use a separate test database, never your real data. Many teams use the supertest package to call an app without starting a port; the fetch() approach above needs no extra package.
Mocks: fake functions and fake time
Some code is hard to test directly: it sends SMS messages, charges cards or waits a whole day. A mock replaces the real thing with a fake you control. node:test gives every test a t.mock object:
// src/reminder.js
export function scheduleFeeReminder(student, sendSms) {
setTimeout(() => {
sendSms(student.phone, `Dear ${student.name}, your fee is due tomorrow.`);
}, 24 * 60 * 60 * 1000); // one day later
}// test/reminder.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { scheduleFeeReminder } from "../src/reminder.js";
test("sends the SMS after one day", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const sendSms = t.mock.fn();
scheduleFeeReminder({ name: "Isha", phone: "9812345678" }, sendSms);
assert.equal(sendSms.mock.callCount(), 0);
t.mock.timers.tick(24 * 60 * 60 * 1000); // jump one day ahead instantly
assert.equal(sendSms.mock.callCount(), 1);
assert.deepEqual(sendSms.mock.calls[0].arguments, [
"9812345678",
"Dear Isha, your fee is due tomorrow.",
]);
});✔ sends the SMS after one day (13.6017ms)
t.mock.fn() creates a fake function that records every call, so you can check how many times it was called and with which arguments. t.mock.timers replaces setTimeout with a fake clock that moves only when you call tick(), so a one-day wait takes no time at all. Mocks made through t.mock are reset automatically when the test ends. The test also shows a useful design idea: because sendSms is passed in as an argument, the test can hand in a fake. Mock timers have been stable since Node.js 23.1.
Watch mode, filters and coverage
| Command | What it does |
|---|---|
node --test | Run all test files |
node --test --watch | Rerun tests whenever a file changes |
node --test test/cart.test.js | Run one file |
node --test --test-name-pattern="GST" | Run only tests whose name matches |
node --test --experimental-test-coverage | Show which lines and branches the tests ran |
Inside a file, it.skip() skips a test, it.todo() marks a test you still need to write, and it.only() (with --test-only) runs only the marked tests. The start of the coverage report for this project looks like this:
ℹ file | line % | branch % | funcs % | uncovered lines ℹ ------------------------------------------------------------------ ℹ src | | | | ℹ app.js | 100.00 | 80.00 | 100.00 | ℹ cart.js | 100.00 | 100.00 | 100.00 | ℹ reminder.js | 100.00 | 100.00 | 100.00 |
Coverage shows code your tests never run. 80% branch coverage for app.js means some if paths were never taken. High coverage does not prove the code is correct, but low coverage shows clearly where bugs can hide. In Node.js 26 the coverage option still has the --experimental- prefix.
Common mistakes
- Starting the server inside
app.js. Tests then fight over port 3000. Export the app and listen in a separate file. - Forgetting
awaitin async tests. The test finishes before the check runs and passes by accident. - Using
assert.equal()on objects. Two different objects are never===; useassert.deepEqual(). - Tests that depend on each other's order or on real time. Make each test set up its own data, and mock timers and dates.
- Testing against the production database. Use a separate test database or in-memory data.
Key points
node --testruns test files;node:testprovidesdescribe,it/test, hooks and mocks.- Use
node:assert/strict:equal,deepEqual,throws,rejects,ok. - A failing test shows the file, line, actual and expected values, and the run exits with code 1.
- Test an Express app by exporting it, listening on port
0inbefore(), and calling it withfetch(). t.mock.fn()records calls andt.mock.timersfakes time;--watchand--experimental-test-coveragehelp day to day.
Exercise
Write a function isValidPnr(pnr) in src/pnr.js that returns true only for a string of exactly 10 digits. Then write test/pnr.test.js with at least four tests: a valid PNR, a 9-digit string, a string with a letter, and a number instead of a string. Run them with npm test.
Show solution
// src/pnr.js
export function isValidPnr(pnr) {
return typeof pnr === "string" && /^\d{10}$/.test(pnr);
}// test/pnr.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isValidPnr } from "../src/pnr.js";
describe("isValidPnr", () => {
it("accepts 10 digits", () => assert.equal(isValidPnr("4521098765"), true));
it("rejects 9 digits", () => assert.equal(isValidPnr("452109876"), false));
it("rejects letters", () => assert.equal(isValidPnr("45210A8765"), false));
it("rejects numbers", () => assert.equal(isValidPnr(4521098765), false));
});▶ isValidPnr ✔ accepts 10 digits (2.342ms) ✔ rejects 9 digits (3.8657ms) ✔ rejects letters (0.5241ms) ✔ rejects numbers (0.6482ms) ✔ isValidPnr (10.3429ms)
The regular expression ^\d{10}$ matches exactly ten digits from start to end, and the typeof check rejects numbers, which could lose leading zeros. Your times will differ.
Quick Quiz
1. Which command runs all test files in a project with the built-in runner?
node --test finds files such as *.test.js and runs them.
2. Which assertion should you use to compare two arrays by their contents?
deepEqual compares structure and values; equal checks identity with ===.
3. Why does the API test call app.listen(0)?
The real port is read with server.address().port after the server starts listening.
4. What does t.mock.timers.tick(5000) do?
Mock timers let you test delayed code instantly.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
