Node.js · Production

Testing

Node has a test runner built in — no extra framework needed. Combined with Supertest for HTTP, you can test functions, services and whole API endpoints in milliseconds.

Advanced 6 min Lesson 33 of 37Examples target Node.js 22 LTS.
Before you start:REST API Development
0/37 in Node.js
On this page · 6 min · Advanced

Learning objectives#

  • Write tests with node:test and node:assert
  • Test async code and errors
  • Mock dependencies with the built-in mock
  • Test Express routes with Supertest

Explanation#

import { test, describe } from "node:test" and import assert from "node:assert/strict". Run with node --test (it finds *.test.js files).

Use assert.equal / deepEqual for values, assert.rejects for async errors. Async tests just return a promise (use async functions).

Mocks: t.mock.fn() creates a fake function that records calls; mock.method(object, "name") temporarily replaces a method. Test units without real databases or networks.

For APIs, export the Express app without calling listen, then use Supertest: await request(app).post("/api/tasks").send({...}).expect(201).

Aim for many fast unit tests of services and validation, plus HTTP tests for each endpoint’s contract (status codes and JSON shape). Run them in CI on every push.

Syntax#

JavaScript Illustrative
import { test } from 'node:test';
import assert from 'node:assert/strict';


test('adds GST', () => {
  assert.equal(withGst(100), 118);
});
// node --test

Code example: Unit, mock and HTTP tests in one file#

JavaScriptcart.test.js Illustrative
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import request from 'supertest';


// ── Code under test ──
export function cartTotal(items, coupon) {
  const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
  if (coupon && coupon !== 'SAVE10') throw new Error('Unknown coupon');
  return coupon ? Math.round(subtotal * 0.9) : subtotal;
}


export function createApp(paymentGateway) {
  const app = express();
  app.use(express.json());
  app.post('/api/checkout', async (req, res) => {
    const total = cartTotal(req.body.items ?? []);
    if (total === 0) return res.status(400).json({ error: 'Cart is empty' });
    const receipt = await paymentGateway.charge(total);
    res.status(201).json({ total, receipt });
  });
  return app;
}


// ── Tests ──
describe('cartTotal', () => {
  test('sums price × qty', () => {
    assert.equal(cartTotal([{ price: 20, qty: 3 }, { price: 60, qty: 1 }]), 120);
  });
  test('applies SAVE10', () => {
    assert.equal(cartTotal([{ price: 100, qty: 1 }], 'SAVE10'), 90);
  });
  test('rejects unknown coupons', () => {
    assert.throws(() => cartTotal([], 'FREE'), /Unknown coupon/);
  });
});


describe('POST /api/checkout', () => {
  test('charges the gateway and returns 201', async t => {
    const gateway = { charge: t.mock.fn(async amount => `rcpt-${amount}`) };
    const res = await request(createApp(gateway))
      .post('/api/checkout')
      .send({ items: [{ price: 50, qty: 2 }] })
      .expect(201);
    assert.deepEqual(res.body, { total: 100, receipt: 'rcpt-100' });
    assert.equal(gateway.charge.mock.callCount(), 1);
  });


  test('returns 400 for an empty cart and never charges', async t => {
    const gateway = { charge: t.mock.fn() };
    await request(createApp(gateway)).post('/api/checkout').send({ items: [] }).expect(400);
    assert.equal(gateway.charge.mock.callCount(), 0);
  });
});
Expected output
node --test reports two suites with 5 passing tests: cartTotal (sums price × qty, applies SAVE10, rejects unknown coupons) and POST /api/checkout (charges the gateway and returns 201, returns 400 for an empty cart and never charges) — # pass 5, # fail 0.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. cartTotal is a pure function, tested directly with assert.equal and assert.throws.
  2. createApp receives its payment gateway, so tests can pass a mock instead of a real one.
  3. t.mock.fn records calls; callCount checks the gateway was (or was not) charged.
  4. Supertest sends real HTTP requests to the app without listening on a port.
  5. .expect(201) asserts the status; res.body is the parsed JSON.

Real-world use case#

A team runs node --test in GitHub Actions on every pull request. A change that accidentally charged customers for empty carts fails the “never charges” test before review.

Common mistakes#

Calling listen in the module under test

Tests then fight over ports and hang. Export the app; call listen only in server.js.

Testing with real external services

Real payment or email providers make tests slow, flaky and expensive. Inject dependencies and mock them.

Not awaiting async assertions

A missing await lets the test finish before the request — it passes even when the API is broken.

Best practices#

  • Use node:test + assert/strict (or Vitest/Jest)
  • Inject dependencies for easy mocking
  • Test endpoints with Supertest
  • Run tests in CI

Remember this#

node --test runs *.test.js files.

Export the app; don’t listen in tests.

t.mock.fn() = fake that records calls.

Practice exercise#

Your task: Write a test for slugify(title) that converts "Hello Node World!" into "hello-node-world".

Need a hint?
lower-case, replace non-alphanumerics with "-", trim dashes.
Show solution
JavaScript Illustrative
import { test } from 'node:test';
import assert from 'node:assert/strict';


export const slugify = title =>
  title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');


test('slugify makes URL-friendly slugs', () => {
  assert.equal(slugify('Hello Node World!'), 'hello-node-world');
});

Mini quiz#

Testing quizMultiple choice Question 1 of 3

Which command runs tests with Node’s built-in runner?

Score so far: 0/0

Want more? Practise all Node.js MCQs · Node.js interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.