Testing with node:test

Node.js · Lesson 25 of 30 8 min read Updated

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:

Text
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.js

With 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:

JavaScript
// 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:

JavaScript
// 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);
  });
});
Terminal
npm test
Output
▶ 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 use test(), which is the same as it().
  • A test passes if its function finishes without throwing, and fails if it throws.
  • node:assert/strict gives 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, and assert.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:

Output
▶ 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():

JavaScript
// 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);
  });
});
Output
▶ 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:

JavaScript
// 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
}
JavaScript
// 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.",
  ]);
});
Output
✔ 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

CommandWhat it does
node --testRun all test files
node --test --watchRerun tests whenever a file changes
node --test test/cart.test.jsRun one file
node --test --test-name-pattern="GST"Run only tests whose name matches
node --test --experimental-test-coverageShow 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:

Output
ℹ 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 await in async tests. The test finishes before the check runs and passes by accident.
  • Using assert.equal() on objects. Two different objects are never ===; use assert.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 --test runs test files; node:test provides describe, 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 0 in before(), and calling it with fetch().
  • t.mock.fn() records calls and t.mock.timers fakes time; --watch and --experimental-test-coverage help 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
JavaScript
// src/pnr.js
export function isValidPnr(pnr) {
  return typeof pnr === "string" && /^\d{10}$/.test(pnr);
}
JavaScript
// 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));
});
Output
▶ 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?

2. Which assertion should you use to compare two arrays by their contents?

3. Why does the API test call app.listen(0)?

4. What does t.mock.timers.tick(5000) do?

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