Skip to content
JavaScript Basics: Test Small Functions With Node's Built-in Runner

JavaScript Basics: Test Small Functions With Node's Built-in Runner

October 14, 2026

A function can look correct and still mishandle an empty input or a boundary value. Automated tests turn those expectations into checks you can repeat after every change. This tutorial tests a small reading-plan calculator, introduces a deliberate bug, and fixes it without installing a testing package.

Prepare an isolated workspace

You need Linux, Bash, the mktemp, rm, and rmdir utilities, a text editor, and Node.js on your PATH. The examples were tested with Node.js v24.19.0 on Linux. No package manifest, third-party packages, network requests, or environment variables are required. The .mjs extension enables ES modules, so the files can use import and export directly.

Run this in Bash and keep the same terminal open through cleanup:

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

Save both files below as UTF-8 in this temporary directory. The test file imports the calculator, so keep them together.

Define a small, predictable function

Our function takes a page count and a daily target. Both must be safe integers: whole numbers from -Number.MAX_SAFE_INTEGER through Number.MAX_SAFE_INTEGER. The page count may be zero, but the daily target must be positive. This example deliberately rejects numeric strings such as "25" instead of converting them.

Save as reading-plan.mjs:

export function readingPlan(totalPages, pagesPerDay) {
  if (!Number.isSafeInteger(totalPages) || totalPages < 0) {
    throw new RangeError("totalPages must be a nonnegative safe integer");
  }
  if (!Number.isSafeInteger(pagesPerDay) || pagesPerDay <= 0) {
    throw new RangeError("pagesPerDay must be a positive safe integer");
  }
  if (totalPages === 0) {
    return { days: 0, lastDayPages: 0 };
  }

  return {
    days: Math.ceil(totalPages / pagesPerDay),
    lastDayPages: totalPages % pagesPerDay || pagesPerDay,
  };
}

Math.ceil() rounds upward: 25 pages at 10 pages per day needs three days. The % operator gives the remaining pages; a remainder of zero means the final day is full. The earlier zero-page check keeps an empty plan separate from that full-day case.

The function returns data rather than printing it, and it has no files, clocks, or network calls to coordinate. That makes its behavior straightforward to test. The named export lets the next file import it.

Write examples as tests

Node’s built-in node:test module registers named checks. A synchronous test fails when its function throws, including when an assertion fails. See Node’s test runner documentation.

Save as reading-plan.test.mjs:

import test from "node:test";
import assert from "node:assert/strict";
import { readingPlan } from "./reading-plan.mjs";

test("rounds up an unfinished final day", () => {
  assert.deepEqual(readingPlan(25, 10), { days: 3, lastDayPages: 5 });
});

test("keeps a full final day", () => {
  assert.deepEqual(readingPlan(20, 10), { days: 2, lastDayPages: 10 });
});

test("gives zero pages an empty plan", () => {
  assert.deepEqual(readingPlan(0, 10), { days: 0, lastDayPages: 0 });
});

test("handles a one-page book", () => {
  const plan = readingPlan(1, 10);
  assert.equal(plan.days, 1);
  assert.equal(plan.lastDayPages, 1);
});

test("rejects invalid page counts", () => {
  for (const value of [-1, 1.5, "25", NaN, Infinity]) {
    assert.throws(() => readingPlan(value, 10), {
      name: "RangeError",
      message: "totalPages must be a nonnegative safe integer",
    });
  }
});

test("rejects invalid daily targets", () => {
  for (const value of [0, -1, 1.5, "10", NaN, Infinity]) {
    assert.throws(() => readingPlan(25, value), {
      name: "RangeError",
      message: "pagesPerDay must be a positive safe integer",
    });
  }
});

Each test name describes an expected behavior. The last two tests use for...of to check several invalid values with the same expectation.

Importing node:assert/strict makes equal() and deepEqual() use their strict variants. Use equal() for a single value and deepEqual() to compare the contents of these plain objects. Two separately created objects would fail an equal() check even if their properties matched. See Node’s strict assertion mode.

throws() receives a function to call, not the result of calling it. The wrapper () => readingPlan(value, 10) lets the assertion observe the error. Matching the error’s name and message also prevents an unrelated exception from satisfying the test. This is synchronous error testing; rejected promises need assert.rejects() instead. See Node’s throws reference.

Run the tests and read the result

Run this exact file with the compact dot reporter:

if node --test --test-reporter=dot reading-plan.test.mjs; then
  printf 'Exit status: 0\n'
else
  printf 'Exit status: %s\n' "$?"
fi

Observed output on the tested runtime:

......
Exit status: 0

Each dot represents one passing test, so the loops still count as two named tests, not eleven. In the else branch, Bash’s $? holds the failed Node command’s status; print it before running another command. The if block also lets you inspect an intentional failure safely when Bash’s set -e option is enabled.

The dot reporter keeps successful runs compact. A failed test produces X, and this Node version also appends detailed failure diagnostics. Node exits with status 1 when a test fails. Reporter formatting can change between Node versions. See Node’s reporter reference.

Check that the tests catch a real mistake

Use your editor to change just Math.ceil(totalPages / pagesPerDay) to Math.floor(totalPages / pagesPerDay) in reading-plan.mjs, then save it. This introduces a bug: a partial final day is now rounded down. Leave the tests unchanged.

Run again:

if node --test --test-reporter=dot reading-plan.test.mjs; then
  printf 'Exit status: 0\n'
else
  printf 'Exit status: %s\n' "$?"
fi

The output begins with this line:

X..X..

It then prints a Failed tests: section with assertion details. After those diagnostics, the final line is:

Exit status: 1

These are excerpts, not the complete failure output: timings, temporary-directory paths, and stack traces vary. The unfinished final day and one-page book tests catch the bug. The exact-multiple case still passes, which is why testing only a convenient round number would have missed it.

To list every named test and the summary counts, rerun with the spec reporter:

if node --test --test-reporter=spec reading-plan.test.mjs; then
  printf 'Exit status: 0\n'
else
  printf 'Exit status: %s\n' "$?"
fi

The report identifies the failed tests and shows actual versus expected values. For the 25-page case, the returned days value is 2 while the test expects 3. Durations, paths, line numbers, colors, and other presentation details may differ on your machine. Node intentionally exits with status 1 while the bug remains; the surrounding if prints that status and lets you continue.

Now restore Math.ceil(totalPages / pagesPerDay) in reading-plan.mjs and save it. Rerun the original command:

if node --test --test-reporter=dot reading-plan.test.mjs; then
  printf 'Exit status: 0\n'
else
  printf 'Exit status: %s\n' "$?"
fi

Expected output after the fix:

......
Exit status: 0

Fix the implementation to meet the agreed behavior; do not change a correct expected result merely to make a test pass. Passing these tests establishes the cases we checked, not proof that every possible input works.

Clean up

Remove only the two files created for this tutorial, then remove the empty directory:

rm -- reading-plan.mjs reading-plan.test.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Start with one ordinary example, then add empty input, boundary values, and invalid inputs. Keep test data explicit and rerun the same checks after changing the function. That small habit makes future edits much safer.

Acknowledgements

This post was created with assistance from AI.

Last updated on