Skip to content
aviral gupta

// I4.4 · ~36 min · Intermediate

Testing with node:test and assert

After this lesson you can write tests for your own functions with what Node.js already has, run them with one command, and read a failing report without guessing.

Lesson 4 of 5 in I4 Errors, debugging and testing

You will be able to

  • Write tests with test() from node:test and run them with node --test
  • Pick the right assertion: equal, deepEqual, throws, rejects or match, with a message
  • Read the test report: which test failed, actual against expected, and the exit code
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I4.1: a function that throws inside try. What does this print?

    try {
      JSON.parse("{");
      console.log("parsed");
    } catch (error) {
      console.log("caught", error.name);
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Both objects look the same. What does this print?

    import assert from "node:assert/strict";
    
    try {
      assert.equal({qty: 2}, {qty: 2});
      console.log("same");
    } catch (error) {
      console.log(error.message.split("\n")[0]);
    }
  3. Practice · Activity 3 of 7

    Fill in the name of the function that defines a test.

    import {____} from "node:test";
    import {} from "node:test";
  4. Practice · Activity 4 of 7

    This file is run with node --test. What does the summary say?

    import {test} from "node:test";
    import assert from "node:assert/strict";
    
    test("adds", () => {
      assert.equal(1 + 1, 2);
    });
    
    test("adds cents", () => {
      assert.equal(0.1 + 0.2, 0.3);
    });
    
    test("lists", () => {
      assert.deepEqual([1, 2].map((n) => n * 2), [2, 4]);
    });
  5. Practice · Activity 5 of 7

    Which assertion passes for assert.____([5, 0.3], [5, 0.3])?

  6. Brain teaser · Activity 6 of 7

    Brain teaser. loadUser(1) resolves, so the assertion should fail. The file is cart.test.js. Which lines of node --test are marked ✖?

    import {test} from "node:test";
    import assert from "node:assert/strict";
    
    async function loadUser(id) {
      if (id < 1) throw new RangeError("id must be positive");
      return {id, name: "Ada"};
    }
    
    test("a bad id is rejected", () => {
      assert.rejects(loadUser(1), RangeError);
    });
  7. Apply · Activity 7 of 7

    Mini-task: in cart.test.js, write four tests for parseQty(text), which returns a whole number of at least 1 and throws a RangeError with the message Not a quantity: <text> otherwise. Test a valid value with equal, a list of values with deepEqual, the RangeError for "0" with throws, and the message for "abc" with throws and a regular expression. Run node --test until it reports 4 passed.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Each kind of assertion, passing and failing

Instead of the test runner, a small check() function runs each check in try...catch and prints the first line of the error, which is how node --test decides too, but without timings, so the output stays the same every run. Two checks fail on purpose: equal on two arrays, and an expectation that is wrong.

main.js

import assert from "node:assert/strict";

function lineTotal(item) {
  if (!Number.isInteger(item.qty) || item.qty < 1) {
    throw new RangeError("Quantity must be at least 1");
  }
  return Math.round(item.price * item.qty * 100) / 100;
}

// test() from node:test does the same: run the function, fail if it throws.
let failed = 0;
function check(name, fn) {
  try {
    fn();
    console.log("✔", name);
  } catch (error) {
    failed++;
    console.log("✖", name);
    console.log("  " + error.message.split("\n")[0]);
  }
}

check("2 x 2.50 is 5", () => assert.equal(lineTotal({price: 2.5, qty: 2}), 5));
check("3 x 0.10 is rounded to 0.3", () => assert.equal(lineTotal({price: 0.1, qty: 3}), 0.3));
check("a cart maps to its totals", () => {
  const cart = [{price: 2.5, qty: 2}, {price: 0.1, qty: 3}];
  assert.deepEqual(cart.map(lineTotal), [5, 0.3]);
});
check("a quantity of 0 is refused", () => {
  assert.throws(() => lineTotal({price: 1, qty: 0}), RangeError);
});
check("the message names the rule", () => {
  assert.throws(() => lineTotal({price: 1, qty: 0}), /at least 1/);
});
check("arrays compared with equal", () => assert.equal([5, 0.3], [5, 0.3]));
check("a wrong expectation", () => {
  assert.equal(lineTotal({price: 1.2, qty: 2}), 2.5, "1.2 x 2 should be 2.40");
});

console.log(`${failed} of 7 failed`);

Run it with

node main.js

Output

✔ 2 x 2.50 is 5
✔ 3 x 0.10 is rounded to 0.3
✔ a cart maps to its totals
✔ a quantity of 0 is refused
✔ the message names the rule
✖ arrays compared with equal
  Values have same structure but are not reference-equal:
✖ a wrong expectation
  1.2 x 2 should be 2.40
2 of 7 failed
  • equal suits numbers and strings; for arrays and objects, deepEqual compares the contents.
  • throws takes a function to call, then a class or a regular expression the error must match.
  • A message argument replaces assert’s default text, so the report says what was meant.

Exercises

Exercise 1 of 3

Assertions with a message

checkOrder(order) guards the start of a checkout with assert: an order needs at least one item, and every quantity must be a whole number of at least 1. When a check fails, the AssertionError should say which rule broke: an order needs at least one item, or every quantity must be at least 1. The starter checks the right things, but its messages are assert’s default text.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: what message does the AssertionError have now?

  2. Hint 2

    Every assert function takes a message as its last argument.

  3. Hint 3

    assert.ok(order.items.length > 0, "an order needs at least one item")

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import assert from "node:assert/strict";

export function checkOrder(order) {
  assert.ok(order.items.length > 0, "an order needs at least one item");
  for (const item of order.items) {
    assert.ok(Number.isInteger(item.qty) && item.qty >= 1, "every quantity must be at least 1");
  }
  return true;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import assert from "node:assert/strict";

export function checkOrder(order) {
  assert.ok(order.items.length > 0);
  for (const item of order.items) {
    assert.ok(Number.isInteger(item.qty) && item.qty >= 1);
  }
  return true;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {checkOrder} from './main.js';

test('a good order passes', () => {
  assert.equal(checkOrder({items: [{qty: 2}]}), true, 'checkOrder should return true');
});

test('an empty order names its rule', () => {
  assert.throws(() => checkOrder({items: []}), {name: 'AssertionError', message: 'an order needs at least one item'});
});

test('a quantity of 0 names its rule', () => {
  assert.throws(() => checkOrder({items: [{qty: 0}]}), {name: 'AssertionError', message: 'every quantity must be at least 1'});
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

Close enough for money

assertClose(actual, expected, tolerance = 1e-9) should pass when two numbers differ by at most tolerance, and otherwise throw an AssertionError with the message <actual> is not within <tolerance> of <expected>. The starter uses assert.equal, so assertClose(0.1 + 0.2, 0.3) fails although the difference is tiny.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: which one fails, and what does the diff show as actual?

  2. Hint 2

    Compare the size of the difference: Math.abs(actual - expected) <= tolerance.

  3. Hint 3

    assert.ok(condition, message) throws an AssertionError with your message.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import assert from "node:assert/strict";

export function assertClose(actual, expected, tolerance = 1e-9) {
  assert.ok(
    Math.abs(actual - expected) <= tolerance,
    `${actual} is not within ${tolerance} of ${expected}`
  );
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import assert from "node:assert/strict";

export function assertClose(actual, expected, tolerance = 1e-9) {
  assert.equal(actual, expected);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {assertClose} from './main.js';

test('0.1 + 0.2 is close to 0.3', () => {
  assertClose(0.1 + 0.2, 0.3);
});

test('0.5 is not close to 0.3', () => {
  assert.throws(() => assertClose(0.5, 0.3), {name: 'AssertionError', message: '0.5 is not within 1e-9 of 0.3'});
});

test('a wider tolerance accepts 2.44 for 2.4', () => {
  assertClose(2.44, 2.4, 0.05);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Fix what the report shows

cartTotal(items) should add price × qty for every line and round the result to cents. The test for a cart of 1.10 × 3 and 0.20 × 1 fails: read the line after + in its report, the value cartTotal really returned. Fix cartTotal, not the test.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests and read the line after + in the diff.

  2. Hint 2

    1.1 × 3 is 3.3000000000000003 in floating point, so the sum is not exactly 3.5.

  3. Hint 3

    Round once at the end: Math.round(total * 100) / 100.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function cartTotal(items) {
  const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
  return Math.round(total * 100) / 100;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function cartTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {cartTotal} from './main.js';

test('an empty cart costs 0', () => {
  assert.equal(cartTotal([]), 0, 'cartTotal([]) should be 0');
});

test('1.10 x 3 and 0.20 x 1 make 3.50', () => {
  assert.equal(cartTotal([{price: 1.1, qty: 3}, {price: 0.2, qty: 1}]), 3.5);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Comparing objects with equal

import {test} from "node:test";
import assert from "node:assert/strict";

test("the order is copied", () => {
  const order = {id: 7, items: ["tea"]};
  assert.equal(structuredClone(order), order);
});

What Node.js prints

AssertionError [ERR_ASSERTION]: Values have same structure but are not reference-equal:

Why, and the fix

equal compares with Object.is, and a copy is a different object, so it fails even though everything inside is the same. To compare what is inside arrays and objects, use assert.deepEqual(structuredClone(order), order).

Calling the function instead of passing it to throws

import {test} from "node:test";
import assert from "node:assert/strict";

function parseQty(text) {
  const qty = Number(text);
  if (!Number.isInteger(qty) || qty < 1) throw new RangeError("Quantity must be at least 1");
  return qty;
}

test("0 is refused", () => {
  assert.throws(parseQty("0"), RangeError);
});

What Node.js prints

RangeError: Quantity must be at least 1

Why, and the fix

parseQty("0") runs before assert.throws is even called, so the RangeError escapes and fails the test. assert.throws needs a function it can call and watch: assert.throws(() => parseQty("0"), RangeError).

Not awaiting assert.rejects

import {test} from "node:test";
import assert from "node:assert/strict";

async function loadUser(id) {
  if (id < 1) throw new RangeError("id must be positive");
  return {id, name: "Ada"};
}

test("a bad id is rejected", () => {
  assert.rejects(loadUser(1), RangeError);
});

What Node.js prints

AssertionError [ERR_ASSERTION]: Missing expected rejection (RangeError).

Why, and the fix

assert.rejects returns a promise. Without await, the test returns at once and is marked as passed; the failed check arrives after the test has ended, as a note about asynchronous activity, and node --test marks the whole file as failed. Make the test async and write await assert.rejects(loadUser(1), RangeError).

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A test is a function that must not throw

import {test} from "node:test" and write test("name", () => { … }). A test passes when its function returns without throwing, and fails when it throws, whether the error comes from an assertion or from a bug such as a TypeError. An async test fails when its promise rejects. node --test finds files such as cart.test.js in the folder and its subfolders, runs them, and prints one line per test.

One assertion for each kind of check

From node:assert/strict: equal(actual, expected) compares with Object.is, so it suits numbers and strings, but two objects are only equal if they are the same object. deepEqual compares contents, for arrays and objects. throws(() => fn(), RangeError) expects a function to throw; await assert.rejects(promise, TypeError) does the same for a promise. match(text, /regex/) checks a string. Every assertion takes a last message argument that replaces the default text.

Reading the report

Each test gets a line: ✔ for passed, ✖ for failed. The summary counts tests, pass and fail. Under "failing tests" each failure shows the test’s file and line, the error, and for equal and deepEqual a diff: + is the actual value your code produced, - is the expected value. If any test fails, the exit code is 1, which is what makes a CI job fail.

Sources

Last reviewed October 5, 2026