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.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
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#
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#
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);
});
});
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#
- cartTotal is a pure function, tested directly with assert.equal and assert.throws.
- createApp receives its payment gateway, so tests can pass a mock instead of a real one.
- t.mock.fn records calls; callCount checks the gateway was (or was not) charged.
- Supertest sends real HTTP requests to the app without listening on a port.
- .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#
Tests then fight over ports and hang. Export the app; call listen only in server.js.
Real payment or email providers make tests slow, flaky and expensive. Inject dependencies and mock them.
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?
Show solution
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#
Which command runs tests with Node’s built-in runner?
Want more? Practise all Node.js MCQs · Node.js interview questions
My notes#
Saved in this browser.