Node.js and Express course menu
Testing Node.js and Express APIs (node:test and Supertest)
Write automated tests for Node.js code and Express APIs: the built-in node:test runner and assert, unit tests for pure functions, integration tests for routes with Supertest, testing errors and validation, test isolation with fresh app instances, and running tests in CI.
On this page
Automated tests run your code with known inputs and check the results, so you can change and refactor without breaking things. Node.js has a built-in test runner, node:test, with assertions in node:assert. For HTTP endpoints, Supertest sends requests to an Express app without starting a real server.
Unit tests with node:test#
A unit test checks one small piece of logic in isolation:
export function cartTotal(items, { couponPercent = 0 } = {}) {
if (couponPercent < 0 || couponPercent > 50) throw new RangeError("couponPercent must be between 0 and 50");
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const discounted = subtotal * (1 - couponPercent / 100);
return Math.round(discounted * 1.18 * 100) / 100; // add 18% GST
}import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { cartTotal } from "../src/pricing.js";
describe("cartTotal", () => {
test("adds GST to the subtotal", () => {
assert.equal(cartTotal([{ price: 100, qty: 2 }]), 236);
});
test("applies a coupon before GST", () => {
assert.equal(cartTotal([{ price: 1000, qty: 1 }], { couponPercent: 10 }), 1062);
});
test("an empty cart costs nothing", () => {
assert.equal(cartTotal([]), 0);
});
test("rejects coupons above 50%", () => {
assert.throws(() => cartTotal([], { couponPercent: 80 }), RangeError);
});
});node --test▶ cartTotal
✔ adds GST to the subtotal (2.2259ms)
✔ applies a coupon before GST (0.2573ms)
✔ an empty cart costs nothing (0.2399ms)
✔ rejects coupons above 50% (1.6231ms)
✔ cartTotal (6.8069ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 216.06node --test finds files named like *.test.js (and others matching its default patterns) and runs them. Use node:assert/strict, which compares with ===; assert.deepEqual compares objects and arrays by content, and assert.throws / assert.rejects check errors. (Durations vary between runs.)
Integration tests for routes with Supertest#
npm install -D supertestAn app created by a function (see Project Structure) can be tested without calling listen:
import express from "express";
export function createApp() {
const app = express();
app.use(express.json());
const notes = [];
app.get("/api/notes", (req, res) => res.json(notes));
app.post("/api/notes", (req, res) => {
const { text } = req.body;
if (typeof text !== "string" || !text.trim()) return res.status(400).json({ error: "text is required" });
const note = { id: notes.length + 1, text: text.trim() };
notes.push(note);
res.status(201).json(note);
});
app.get("/api/notes/:id", (req, res) => {
const note = notes.find((n) => n.id === Number(req.params.id));
if (!note) return res.status(404).json({ error: "Note not found" });
res.json(note);
});
return app;
}import assert from "node:assert/strict";
import { beforeEach, describe, test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
describe("notes API", () => {
let app;
beforeEach(() => {
app = createApp(); // fresh app and data for each test
});
test("starts empty", async () => {
const res = await request(app).get("/api/notes");
assert.equal(res.status, 200);
assert.deepEqual(res.body, []);
});
test("creates a note and fetches it", async () => {
const created = await request(app).post("/api/notes").send({ text: " Buy milk " });
assert.equal(created.status, 201);
assert.deepEqual(created.body, { id: 1, text: "Buy milk" });
const fetched = await request(app).get("/api/notes/1");
assert.equal(fetched.status, 200);
assert.equal(fetched.body.text, "Buy milk");
});
test("rejects an empty note", async () => {
const res = await request(app).post("/api/notes").send({ text: " " });
assert.equal(res.status, 400);
assert.match(res.body.error, /required/);
});
test("returns 404 for a missing note", async () => {
const res = await request(app).get("/api/notes/99");
assert.equal(res.status, 404);
});
});node --test test/notes.test.js▶ notes API
✔ starts empty (73.7608ms)
✔ creates a note and fetches it (47.4061ms)
✔ rejects an empty note (17.8078ms)
✔ returns 404 for a missing note (10.395ms)
✔ notes API (151.8061ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 648.3953Supertest's request(app) starts the app on a temporary port, sends the request, and closes it again. send(obj) sends JSON, and res.body is the parsed response. Because beforeEach creates a new app each time, tests do not depend on each other's data and can run in any order.
What a failing test looks like#
When an assertion fails, the runner shows the expected and actual values. This test expects the wrong id for the first note:
import assert from "node:assert/strict";
import { test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
test("first note gets id 2 (wrong expectation)", async () => {
const res = await request(createApp()).post("/api/notes").send({ text: "Buy milk" });
assert.deepEqual(res.body, { id: 2, text: "Buy milk" });
});node --test test/wrong.test.js✖ first note gets id 2 (wrong expectation) (65.1443ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 542.4638
✖ failing tests:
test at test\wrong.test.js:6:1
✖ first note gets id 2 (wrong expectation) (65.1443ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
+ id: 1,
- id: 2,
text: 'Buy milk'
}
at TestContext.<anonymous> (file:///C:/Projects/laviyx-course-demo/my-app/test/wrong.test.js:8:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1208:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:385:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: { id: 1, text: 'Buy milk' },
expected: { id: 2, text: 'Buy milk' },
operator: 'deepStrictEqual',
diff: 'simple'
}The + actual - expected diff shows exactly which value differs (id: 1 versus id: 2), and the stack trace points to the line in the test file. The process exits with a non-zero code, which makes CI mark the build as failed.
What to test#
- Unit tests for business logic: pricing, validation rules, permissions; fast and numerous.
- Integration tests for routes: status codes, response bodies, validation errors, auth (401/403), using a test database or a fresh in-memory one.
- A few end-to-end tests of whole user journeys against a running deployment.
Test the behaviour clients rely on (status codes, response shapes, error messages), not private implementation details.
Running tests automatically#
Add a script and run it in continuous integration (CI) on every push, for example with GitHub Actions:
{
"type": "module",
"scripts": {
"test": "node --test"
}
}Then npm test runs everything. Other popular test runners are Vitest and Jest; they offer watch mode, mocking helpers and coverage, and Supertest works with all of them.
Common mistakes#
Practice#
Practice exercise
Write a test for cartTotal that checks a cart with two different items and a 25% coupon: notebook ₹60 × 3 and pen ₹25 × 4.
Show hint
Subtotal is 280; after 25% off it is 210; with 18% GST it is 247.8.
Show solution
import assert from "node:assert/strict";
import { test } from "node:test";
import { cartTotal } from "../src/pricing.js";
test("two items with a 25% coupon", () => {
const items = [{ price: 60, qty: 3 }, { price: 25, qty: 4 }];
assert.equal(cartTotal(items, { couponPercent: 25 }), 247.8);
});node --test test/coupon.test.js✔ two items with a 25% coupon (0.8806ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 147.545Key takeaway#
Finished this lesson?