Testing Node.js

Write automated tests for Node.js code with the built-in test runner and assert module: unit tests, describe and it, async tests, mocking functions and timers, testing Express APIs end to end, and running tests in watch mode and CI.

Lesson 21 of 43Advanced18 min read

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

JavaScript
// 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);
  });
});
Terminal
node --test                 # finds files like *.test.js and runs them
node --test --watch         # re-runs when files change
node --test --experimental-test-coverage
  • assert.equal compares with ===; assert.deepEqual compares 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, beforeEach and afterEach set 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:

JavaScript
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() and charge.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 with mock.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:

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

JavaScript
test('pays with a saved card', { skip: 'waiting on payments API' }, () => { /* ... */ });
test.todo('applies a discount code');
test('focus while debugging', { only: true }, () => { /* ... */ });
  • skip reports the test as skipped, with your reason, instead of running it.
  • test.todo records a test you plan to write; it shows in the summary but doesn't fail the run.
  • In Node 22, only has no effect unless you also pass --test-only, so a forgotten only won'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 --watch

Node.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.

Example 1

Checks with node:assert

Node.js
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}`);
  }
}
Output
pass  10% off
pass  unknown code
pass  rounding
pass  negative price
pass  objects
FAIL  deliberate failure: expected 6, got 5
Line-by-line explanation
  1. Line 1The strict version of assert compares with ===.
  2. 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.
  3. Line 13assert.throws passes only if the function throws the given error type.
  4. Line 14deepEqual compares objects and arrays by their contents.
  5. Line 15A failing assertion throws an AssertionError with the expected and actual values.
Example 2

A test file run with node --test

Node.js
// 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);
  });
});
Terminal
node --test --test-reporter=tap price.test.mjs | grep -E "^# (tests|pass|fail)"
Output
# tests 3
# pass 3
# fail 0
Line-by-line explanation
  1. Line 2test and describe come from the built-in node:test module.
  2. Line 7describe groups related tests under one name.
  3. Lines 8 to 10Each test has a name that reads as a sentence about the behaviour.
  4. Lines 14 to 17Async tests are awaited, so promises are checked properly.
Example 3

Mocks and an API test

Node.js
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();
});
Output
All API checks passed
Line-by-line explanation
  1. Line 5The app receives its payment service as a parameter, so tests can pass in a fake.
  2. Line 16mock.fn creates a fake charge function that records every call.
  3. Line 17The app starts on any unused port for the test.
  4. Lines 21 to 26Real HTTP requests check status codes for success, a declined card and invalid input.
  5. Line 28The invalid request never reached the payment service.
  6. Line 29The recorded arguments show exactly what was charged.

Common Node.js Advanced Mistakes

Testing against real external services

Don't
test('charges the card', async () => {
  await stripe.charges.create({ amount: 500 });   // slow, costs money, fails when offline
});
Do
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

Don't
test('rejects unknown users', () => {
  assert.rejects(loadUser(-1));       // not awaited: the test finishes before the check
});
Do
test('rejects unknown users', async () => {
  await assert.rejects(loadUser(-1));
});

Comparing objects with equal

Don't
assert.equal(getUser(), { id: 1 });   // fails: two different objects are never ===
Do
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.

Fill in the blankseasy

Complete the check that compares two arrays by content.

assert.([1, 2], [1, 2]);
Multiple choiceeasy

Which command runs Node's built-in test runner?

Multiple choicemedium

How do you check that an async function rejects?

Predict the outputmedium

What does fn.mock.callCount() return here?

const fn = mock.fn();
fn(1);
fn(2);
fn(3);
Multiple choicehard

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.

  1. Which module provides test, describe and it?

    • node:assert
    • node:spec
    • node:check
    • node:test
  2. What does assert.equal use to compare in node:assert/strict?

    • ===
    • ==
    • Object.is only for numbers
    • JSON.stringify
  3. 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
  4. 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
  5. What should good tests focus on?

    • Private helper functions
    • Observable behaviour: inputs, outputs and errors
    • Line-by-line implementation
    • Only the happy path