NewValidate LLM output with Zod and retry when it's wrong →
w3/w3-tutorials

Test TypeScript with Node's built-in test runner

Tests, mocks and coverage with no dependencies and no config file, using only what ships with Node.

Updated Nov 8, 20262 min readBeginnernode 22.22

What you'll build

A small test suite for a shopping cart, using only what ships with Node: node:test for the runner, node:assert for assertions, and built-in mocks and coverage. There's nothing to install and no config file.

Create an empty folder and add the code below. Recent Node 22 releases run .ts files directly by removing the types, so you don't need a build step either.

1. The code under test

cart.ts
export type Item = { price: number; quantity: number };

export function total(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export async function checkout(items: Item[], charge: (amount: number) => Promise<string>) {
  if (items.length === 0) throw new Error("Cart is empty");
  return charge(total(items));
}

2. Write the tests

If you've used Jest or Vitest, describe and it will look familiar. assert/strict makes every comparison strict by default.

cart.test.ts
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { checkout, total } from "./cart.ts";

describe("total", () => {
  it("adds price times quantity", () => {
    assert.equal(total([{ price: 10, quantity: 2 }, { price: 5, quantity: 1 }]), 25);
  });

  it("returns 0 for an empty cart", () => {
    assert.equal(total([]), 0);
  });
});

describe("checkout", () => {
  it("charges the cart total", async () => {
    const charge = mock.fn(async (_amount: number) => "receipt_1");

    const receipt = await checkout([{ price: 20, quantity: 2 }], charge);

    assert.equal(receipt, "receipt_1");
    assert.equal(charge.mock.callCount(), 1);
    assert.deepEqual(charge.mock.calls[0].arguments, [40]);
  });

  it("rejects an empty cart", async () => {
    await assert.rejects(checkout([], async () => "x"), /Cart is empty/);
  });
});

3. Run them

--test finds files such as *.test.ts by itself:

terminal
node --test
output
# tests 4
# suites 2
# pass 4
# fail 0

4. Add coverage

terminal
node --test --experimental-test-coverage
output
# file         | line % | branch % | funcs %
# cart.test.ts | 100.00 |   100.00 |   87.50
# cart.ts      | 100.00 |   100.00 |  100.00

Should you switch?

You needUse
Tests for a library, CLI or backendnode:test
No dependencies to keep updatednode:test
Component tests with a browser-like DOMVitest
Snapshot-heavy or plugin-heavy suitesVitest or Jest

Next steps

  • Move one small package over and compare how long the suite takes.
  • Add node --test to CI, where a failing test exits with a non-zero code.