Testing in Node.js: node:test, Mocking, Coverage and API Tests with supertest
Testing in Node.js needs no extra package: node:test brings describe/it, t.mock.fn and t.mock.method, mock timers and snapshots built in, while supertest calls the Express app end to end with fake repositories without opening a port. With real output: the green tick and red file of assert.rejects without await, 26 tests in 0.2 seconds, stock and BOLA tests, the missing snapshot error and coverage dropping from 80% to 62% once the SQL files are included.
Version note: The examples in this post were run with the built-in test runner of Node.js 26.13.1 (LTS), Express 5.3.0, supertest 7.3.2, jose 6.2.17 and TypeScript 7.0.2.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, Emre tests that an order with an unknown ISBN is rejected. The test has a bug: Dune is in the catalog, so the order will succeed and the assertion is wrong. And an await is missing:
import { it } from "node:test";
import assert from "node:assert/strict";
import { UnknownBookError } from "#domain/errors";
import { createTestServices, DUNE } from "../../tests/helpers.ts";
// Dune is in the catalog, so placeOrder succeeds and this assertion is wrong. Does the test fail?
it("rejects an unknown ISBN", () => {
const { orders } = createTestServices();
assert.rejects(orders.placeOrder("ada", [{ isbn: DUNE, quantity: 1 }]), UnknownBookError);
});
Is the test green or red?
node --test labs/16/guess.test.ts
✔ rejects an unknown ISBN (0.567542ms)
ℹ Error: Test "rejects an unknown ISBN" at labs/16/guess.test.ts:7:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Missing expected rejection (UnknownBookError)." and would have caused the test to fail, but instead triggered an unhandledRejection event.
✖ labs/16/guess.test.ts (115.454458ms)
ℹ tests 2
ℹ pass 1
ℹ fail 1
Both. Because the test function returned synchronously, the test was marked as passed; the promise returned by assert.rejects was rejected only afterwards. Node's test runner caught that late error and failed the file, so CI stays red. But the line shows a green tick, and a careless reader thinks the test passed. It is the counterpart of writing an async void test in xUnit. In this post we turn the API behaviour we have tried by hand so far into lasting tests: node:test, mocks, mock timers, API tests with supertest without opening a port, snapshots and coverage.
First Tests with node:test
Node's built-in test runner has been stable since Node 20. No package to install; node --test finds the given files, runs each file in a separate process and collects the results. Thanks to type stripping, the tests are .ts too. A mapping for anyone who knows xUnit:
| xUnit | node:test |
|---|---|
[Fact] | it(...) or test(...) |
[Theory] + [InlineData] | Generating it calls in a loop over a table |
| Test class | describe(...) |
Constructor / IDisposable | beforeEach / afterEach |
IClassFixture | before / after |
Assert.Equal, Assert.ThrowsAsync | assert.equal, await assert.rejects |
| Moq, NSubstitute | t.mock.fn, t.mock.method |
A table test for the money functions from post 12:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { formatAmount, InvalidAmountError, parseMinorUnits } from "#domain/money";
describe("parseMinorUnits", () => {
// A table of cases: the counterpart of an xUnit [Theory] with [InlineData]
const cases: [text: string, expected: number][] = [
["349,90", 34990],
["349.9", 34990],
["349", 34900],
["19,99", 1999],
[" 0,05 ", 5],
];
for (const [text, expected] of cases) {
it(`parses "${text}" as ${expected}`, () => {
assert.equal(parseMinorUnits(text), expected);
});
}
for (const text of ["", "abc", "1.234,50", "-5", "1,999"]) {
it(`rejects "${text}"`, () => {
assert.throws(() => parseMinorUnits(text), InvalidAmountError);
});
}
});
Use node:assert/strict: there assert.equal compares with ===, so you avoid the loose mode's "1" == 1 surprise. We added three scripts to package.json: test, test:watch and test:coverage. The glob is quoted so that Node, not the shell, expands it; it behaves the same on Windows.
npm test
▶ POST /api/orders
✔ 401 without a token (12.424709ms)
✔ 201 with Location, the customer taken from the token (3.377375ms)
✔ 409 when the stock is not enough (1.342458ms)
✔ 400 with every validation error in one Problem Details body (2.038917ms)
✔ POST /api/orders (19.664542ms)
▶ GET /api/orders/:id
✔ 404 for someone else's order, 200 for the owner and for store staff (3.6645ms)
✔ GET /api/orders/:id (3.761459ms)
...
ℹ tests 26
ℹ suites 8
ℹ pass 26
ℹ fail 0
ℹ duration_ms 204.986042
26 tests in six files finish in 0.2 seconds; none of them reaches SQL Server, the network or a real identity provider.
Fake Repositories and Mocking
In post 12 we put the repositories behind interfaces and passed them to the services through constructors; in tests that pays off. Instead of SQL Server, the in-memory implementations, with a stock level per test:
// Fake repositories: the in-memory implementations, with a stock level chosen per test
export function createTestServices(stock: Record<string, number> = { [DUNE]: 5, [EMMA]: 5 }) {
const bookRepository = new InMemoryBookRepository(catalog);
const orderRepository = new InMemoryOrderRepository(catalog, new Map(Object.entries(stock)));
return {
bookRepository,
orderRepository,
books: new BookService(bookRepository),
orders: new OrderService(bookRepository, orderRepository),
};
}
The stock check test from the plan: with a single copy of Dune, an order for two copies must be rejected, no orderPlaced event may be published and the stock must not change.
it("rejects an order that does not fit the stock and announces nothing", async (t) => {
const { orders, orderRepository } = createTestServices({ [DUNE]: 1, [EMMA]: 5 });
const listener = t.mock.fn();
orders.on("orderPlaced", listener);
// Spy on the repository method; the real implementation still runs
const add = t.mock.method(orderRepository, "add");
await assert.rejects(orders.placeOrder("ada", [{ isbn: EMMA, quantity: 1 }, { isbn: DUNE, quantity: 2 }]), (err: unknown) => {
assert.ok(err instanceof OutOfStockError);
assert.deepEqual(err.isbns, [DUNE]);
return true;
});
assert.equal(add.mock.callCount(), 1);
assert.equal(listener.mock.callCount(), 0);
// Nothing was taken out of stock: the next order for the last copy still succeeds
await orders.placeOrder("alan", [{ isbn: DUNE, quantity: 1 }]);
});
t.mock.fn() creates a function that records its calls; t.mock.method() wraps an existing method and counts the calls, replacing the implementation if you want. Every mock created through t.mock is restored automatically when the test ends; there is no need to restore by hand. The counterpart of Moq's Verify(x => x.Add(...), Times.Once) is add.mock.callCount(). The BOLA rule from post 15 lives in the service, so it is tested without HTTP as well: getForUser must throw OrderNotFoundError for Alan and return the order for Grace (StoreStaff).
A note: the in-memory repository does not stand in for the transaction and the conditional UPDATE in SQL Server. These tests verify the service's rule; the race of two concurrent orders for the last copy can only be tested with a real database (see below).
Mock Timers
The readiness check from post 14 cancels the query if SQL Server does not answer within a second. We do not want to wait a second to test that; mock timers replace setTimeout with a fake clock:
it("reports not-ready when SQL Server does not answer within a second", async (t) => {
// Fake timers: the 1 s timeout passes in no time
t.mock.timers.enable({ apis: ["setTimeout"] });
// A fake pool whose query hangs until it is cancelled, like an unreachable server
let cancel = (): void => {};
const request = {
query: () => new Promise((resolve, reject) => (cancel = () => reject(new Error("Canceled.")))),
cancel: () => cancel(),
};
const pool = { request: () => request } as unknown as sql.ConnectionPool;
const check = createReadinessCheck(pool, { shuttingDown: false });
const pending = check();
t.mock.timers.tick(1_000);
assert.deepEqual(await pending, { status: "not-ready", checks: { sql: "Canceled." } });
});
tick(1_000) moves the fake clock one second forward; the timeout callback runs at once and the test finishes in 3 ms. According to the Node documentation the APIs that can be mocked are setTimeout, setInterval, setImmediate and Date; mocking setTimeout also covers its counterpart in node:timers/promises. TimeProvider and FakeTimeProvider, available since .NET 8, do the same job; the difference is that Node replaces the global timers instead of passing time in through a constructor.
API Tests with supertest
In ASP.NET Core, WebApplicationFactory brings the application up in memory and lets you call it with a real HttpClient. That is why we kept createApp separate from listen since post 11: supertest takes the Express app and hands requests to it directly. The test helper uses the same fake services and a fake token verifier:
// A fake token verifier: tokens are just names, so tests never deal with keys or signatures
const PRINCIPALS: Record<string, Principal> = {
"token-ada": { userId: "ada", roles: ["Customer"] },
"token-alan": { userId: "alan", roles: ["Customer"] },
"token-grace": { userId: "grace", roles: ["StoreStaff"] },
};
// supertest calls the Express app in memory: no port, no SQL Server (the WebApplicationFactory counterpart)
describe("POST /api/orders", () => {
it("401 without a token", async () => {
const { app } = createTestApp();
const res = await request(app).post("/api/orders").send({ lines: [{ isbn: DUNE, quantity: 1 }] });
assert.equal(res.status, 401);
assert.equal(res.headers["www-authenticate"], "Bearer");
});
it("201 with Location, the customer taken from the token", async () => {
const { app } = createTestApp();
const res = await request(app)
.post("/api/orders")
.set("authorization", "Bearer token-ada")
.send({ lines: [{ isbn: DUNE, quantity: 3 }, { isbn: EMMA, quantity: 1 }] })
.expect("content-type", /json/)
.expect(201);
assert.equal(res.headers.location, `/api/orders/${res.body.id}`);
assert.equal(res.body.customerId, "ada");
assert.deepEqual(res.body.total, { amount: 1294.7, currency: "TRY" });
});
});
These tests go through the real middleware chain: request id, helmet, CORS, rate limit, JSON parser, authentication, validation, the error middleware and Problem Details. Only two things are fake: the repositories and token validation. The rate limit test lowers the limit to 2 and checks that the third request gets 429; each test builds its own createApp, so counters are not shared between tests.
So that token validation itself does not stay fake, we test createJwtVerifier with real jose in a separate file. before generates an RSA key pair at the start and publishes the JWKS on port 0 (the operating system picks a free port); after closes the server:
// before/after: the counterpart of an xUnit class fixture
before(async () => {
const pair = await generateKeyPair("RS256");
privateKey = pair.privateKey;
const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "RS256" }] });
server = http.createServer((req, res) => res.end(jwks)).listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
verify = createJwtVerifier({ issuer, audience: "bookstore-api", jwksUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/jwks` });
});
after(() => {
server.close();
});
The key is generated on every run and written nowhere; not even a test private key sits in the repo. This file makes three of the attacks from post 15 (wrong audience, expired token, alg: "none") permanent.
Snapshot Tests
The Problem Details response for a validation error is a long JSON; instead of asserting each field one by one, we compare it with a snapshot:
// The first run with --test-update-snapshots writes the file; later runs compare against it
t.assert.snapshot(res.body);
Without a snapshot file the first run fails; it does not create the file by itself:
Error [ERR_INVALID_STATE]: Invalid state: Cannot read snapshot file '/home/user/bookstore-api/tests/api/orders.api.test.ts.snapshot.' Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag.
npm run test:update-snapshots writes the file; it is committed to the repo and shows up as a change in code review:
exports[`POST /api/orders > 400 with every validation error in one Problem Details body 1`] = `
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"instance": "/api/orders",
"traceId": "req-16-snapshot",
"errors": {
"lines[0].isbn": [
"must be 13 digits starting with 978 or 979"
],
"lines[0].quantity": [
"Too small: expected number to be >=1"
],
"$": [
"Unrecognized key: \\"customerId\\""
]
}
}
`;
The snapshot protects the contract the Angular client depends on: if someone changes the error format, the test breaks and someone has to decide whether the change was intended. We fixed the request id in the test; otherwise a different traceId on every run would break the snapshot. Keep changing fields such as dates and random ids out of snapshots, or pin them first. Snapshot testing has needed no flag since Node 23.4.
Coverage
Coverage is still experimental in Node 26; it is turned on with the --experimental-test-coverage flag. The test:coverage script measures only src and fails if line coverage drops below 70%:
npm run test:coverage
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ middleware | | | |
ℹ auth.ts | 88.89 | 90.91 | 75.00 | 31-34
ℹ errorHandler.ts | 69.49 | 50.00 | 50.00 | 20 27-29 31-34 47 49-50 52-58
ℹ services | | | |
ℹ orderExport.ts | 41.89 | 100.00 | 0.00 | 23-37 40-43 46-49 53-70 73-74
ℹ orderService.ts | 93.33 | 94.44 | 81.82 | 31 43-44 63-64
ℹ all files | 80.41 | 89.47 | 71.32 |
80% in total, above the threshold. But the number hides something: by default only the files the tests loaded are reported, and the SQL repositories are missing from the list because they were never loaded. With --test-coverage-include-all:
ℹ Error: 62.32% line coverage does not meet threshold of 70%.
ℹ pool.ts | 0.00 | 100.00 | 100.00 | 1-15
ℹ sqlBookRepository.ts | 10.47 | 100.00 | 100.00 | 1 4-11 19-86
ℹ sqlOrderRepository.ts | 11.41 | 100.00 | 100.00 | 1-2 5 7 10 18 24-149
ℹ server.ts | 0.00 | 100.00 | 100.00 | 1-44
ℹ all files | 62.32 | 89.47 | 71.32 |
The total dropped to 62% and the script exited with code 1. That is the true picture: none of the transaction, stock check and export we wrote in post 13 runs in these tests. Coverage is not a goal but a map of what is untested; the threshold is there only to catch regressions.
Real SQL Server, Watch Mode and Other Runners
The right way to test the SQL repositories is a real SQL Server. The Node package of Testcontainers can start a SQL Server container at the beginning of a test run and stop it at the end; the tests connect openPool to that container, the db-setup from post 13 creates the schema, and scenarios such as the race for the last copy are tested with real locks. I did not set this up for this post; the whole approach and its .NET counterpart are in the Integration and E2E Testing series. During local development npm run test:watch reruns tests when files change; touching src/domain/money.ts:
Restarted at 11/14/2026, 11:10:20 PM
▶ parseMinorUnits
✔ parseMinorUnits (1.666ms)
Watch mode is still experimental in the documentation. When should you go for Vitest or Jest? If you already use Vitest on the frontend (Angular, React), staying with the same tool makes sense; the mocking ecosystem, parallel isolation options and editor integrations are more mature there. For a plain Node backend, node:test is enough and adds no dependency; I did not run Vitest or Jest for this series.
In Production
Run npm test in CI on every pull request and do not merge while it is red. There is no reason to skip a 0.2-second suite.
Put the coverage threshold slightly below the current value and look at the real picture with --test-coverage-include-all. Tests are written to raise the threshold, not to inflate the count.
Commit snapshot files and read their changes in code review. Running "update snapshots" blindly makes the test meaningless.
Generate secrets in tests too. Test keys, tokens and passwords should be generated during the run; a real-looking key in the repo sets off scanners and people even when it is not real.
Common Pitfalls
Calling assert.rejects without await. As in Guess First: a green tick, a red file.
Sharing global state between tests. A single module-level app or repository carries rate limit counters and stock levels from test to test; let every test build its own createApp.
Using loose assert. The old equal of node:assert compares with ==; use node:assert/strict.
Log noise in tests. The test helper sets logger.level = "silent"; otherwise every request prints a JSON line and the real error lines get lost.
Trusting the in-memory fake and calling the SQL behaviour tested. Transactions, locks and type conversions can only be tested against a real SQL Server.
A Small Experiment
★☆☆ Add the missing await in labs/16/guess.test.ts and fix the test (use an ISBN that really is unknown). How does the output change?
★★☆ Write a supertest test for GET /api/orders/export: Grace must get 200 and text/csv, Ada 403. Place two orders in the in-memory repository and check that the CSV consists of the header and two lines.
★★★ Turn the race for the last copy from post 13 into an integration test: a test that runs when DB_CONNECTION_STRING is set and is skipped with t.skip() otherwise; send two orders at the same time and verify that one gets 201 and the other 409.
The API we built across the series is now tested. In the last post we look back and ahead: the changes from Node.js 22 to 26 (require(esm), type stripping, the Permission Model, Temporal) and what to watch for when moving to the next version under the new release schedule.