Skip to content
aviral gupta

// B5.4 · ~32 min · Beginner

node --test and watch mode

After this lesson you can choose which tests node --test runs, read its output and exit code, mark tests, and keep them re-running while you edit.

Lesson 4 of 5 in B5 Events, input and first tests

You will be able to

  • Run the right test files: the default name patterns, file names and quoted globs
  • Read and choose the output: spec, tap and dot, the summary, the exit code and --test-name-pattern
  • Mark tests with skip, todo and only, re-run them with --watch, and keep the commands as npm scripts
  1. Warm-up · Activity 1 of 7

    Warm-up from B2.1: package.json has "scripts": {"test": "node --test"}. Which commands run that script? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on. A folder holds these five files, each with one test: main.test.js, prices.spec.js, test/check.js, tests.js and notes_test.js. How many tests does node --test run, with no file names?

  3. Practice · Activity 3 of 7

    Fill in the reporter that prints one character per test: a dot for each passing test and X for each failing one.

    node --test --test-reporter=____
    node --test --test-reporter=
  4. Practice · Activity 4 of 7

    Match each command to what it prints for a file with the three tests 3.50 is 350 cents, 0.29 is 29 cents and a word is not a price.

  5. Practice · Activity 5 of 7

    node --test runs this file. toCents('3,50') throws a RangeError, and toCents('-1') would throw one too. What do the pass and fail lines of the summary say, and what is the exit code?

    import {test} from 'node:test';
    import assert from 'node:assert/strict';
    import {toCents} from './main.js';
    
    test('3.50 is 350 cents', () => {
      assert.equal(toCents('3.50'), 350, "toCents('3.50') should be 350");
    });
    
    test.skip('negative prices', () => {
      assert.equal(toCents('-1'), -100, "toCents('-1') should be -100");
    });
    
    test.todo('prices with a comma', () => {
      assert.equal(toCents('3,50'), 350, "toCents('3,50') should be 350");
    });
  6. Brain teaser · Activity 6 of 7

    Brain teaser. One test of this file has {only: true}. You run node --test, with no other flag. Which tests run?

    import {test} from 'node:test';
    import assert from 'node:assert/strict';
    import {toCents} from './main.js';
    
    test('3.50 is 350 cents', () => {
      assert.equal(toCents('3.50'), 350, "toCents('3.50') should be 350");
    });
    
    test('0.29 is 29 cents', {only: true}, () => {
      assert.equal(toCents('0.29'), 29, "toCents('0.29') should be 29");
    });
    
    test('a word is not a price', () => {
      assert.throws(() => toCents('abc'), RangeError, "toCents('abc') should throw a RangeError");
    });
  7. Apply · Activity 7 of 7

    Mini-task. Take main.js and main.test.js from B5.3 (toCents and its tests). In package.json add "test": "node --test" and "test:watch": "node --test --watch". Run npm test, then npm test -- --test-reporter=dot and npm test -- --test-name-pattern=cents. Start npm run test:watch, change Math.round to Math.floor in main.js and save; read the failure, change it back, save again, then stop it with Ctrl+C.

    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

One test file, four ways to run it

prices.test.js has five tests: three plain ones, one skipped and one TODO without a function yet. main.js starts node --test four times as a child process and prints what each run shows up to its first blank line, without the durations, which change on every run. The runs: the default spec reporter, a name pattern, the dot reporter, and the tap reporter for one test.

main.js

import {spawnSync} from 'node:child_process';

// Runs node with these arguments and prints what the terminal shows, up to the
// first blank line, without durations such as (1.23ms) and a few summary lines.
function run(args) {
  console.log('$ node ' + args.join(' '));
  const {status, stdout} = spawnSync(process.execPath, args, {encoding: 'utf8'});
  const shown = stdout.split('\n\n')[0].replace(/ \(\d+(\.\d+)?ms\)/g, '');
  console.log(shown.split('\n').filter((line) => !/suites|cancelled|duration_ms/.test(line)).join('\n').trimEnd());
  console.log('exit code ' + status + '\n');
}

run(['--test']);
run(['--test', '--test-name-pattern=cents']);
run(['--test', '--test-reporter=dot']);
run(['--test', '--test-reporter=tap', '--test-name-pattern=word']);

prices.js

// Turns a price like '3.50' into whole cents.
export function toCents(text) {
  if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new RangeError('not a price: ' + text);
  return Math.round(Number(text) * 100);
}

prices.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {toCents} from './prices.js';

test('3.50 is 350 cents', () => {
  assert.equal(toCents('3.50'), 350, "toCents('3.50') should be 350");
});

test('0.29 is 29 cents', () => {
  assert.equal(toCents('0.29'), 29, "toCents('0.29') should be 29");
});

test.skip('negative prices', () => {
  assert.equal(toCents('-1'), -100, "toCents('-1') should be -100");
});

test.todo('prices with a comma');

test('a word is not a price', () => {
  assert.throws(() => toCents('abc'), RangeError, "toCents('abc') should throw a RangeError");
});

Run it with

node main.js

Output

$ node --test
✔ 3.50 is 350 cents
✔ 0.29 is 29 cents
﹣ negative prices # SKIP
✔ prices with a comma # TODO
✔ a word is not a price
ℹ tests 5
ℹ pass 3
ℹ fail 0
ℹ skipped 1
ℹ todo 1
exit code 0

$ node --test --test-name-pattern=cents
✔ 3.50 is 350 cents
✔ 0.29 is 29 cents
ℹ tests 2
ℹ pass 2
ℹ fail 0
ℹ skipped 0
ℹ todo 0
exit code 0

$ node --test --test-reporter=dot
.....
exit code 0

$ node --test --test-reporter=tap --test-name-pattern=word
TAP version 13
# Subtest: a word is not a price
ok 1 - a word is not a price
  ---
  type: 'test'
  ...
1..1
# tests 1
# pass 1
# fail 0
# skipped 0
# todo 0
exit code 0
  • node --test found prices.test.js by its name; main.js and prices.js are not test files.
  • The skipped and the TODO test are counted apart from pass and fail.
  • The name pattern ran two tests and left the other three out of the output.
  • describe and it, hooks, mocks and coverage are further node:test features; the Intermediate level teaches them.

Exercises

Exercise 1 of 2

Which files are test files?

Write isTestFile(path) for paths such as 'src/prices.test.js', with / between folders. It returns true when node --test, run without file names, would run the file. The extension must be js, cjs, mjs, ts, cts or mts. Then the name before it must be test, start with test-, or end with .test, -test or _test; or one of the folders is named exactly test. So main.test.js and test/unit/f.js count, but latest.js and contest/x.js do not.

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

    Split the path: const parts = path.split('/'); the file name is parts.at(-1), the folders are parts.slice(0, -1).

  2. Hint 2

    Check the extension first with /^(.+)\.(js|cjs|mjs|ts|cts|mts)$/; its first group is the name before the extension.

  3. Hint 3

    Then the name: === 'test', startsWith('test-'), or /[.\-_]test$/ for the three endings. latest ends in test but has no separator before it.

Show a solution

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

// True when node --test, run without file names, would run this file.
// path uses / between folders, such as 'src/prices.test.js'.
export function isTestFile(path) {
  const parts = path.split('/');
  const match = /^(.+)\.(js|cjs|mjs|ts|cts|mts)$/.exec(parts.at(-1));
  if (!match) return false;
  if (parts.slice(0, -1).includes('test')) return true; // anywhere inside a folder named test
  const stem = match[1];
  return stem === 'test' || stem.startsWith('test-') || /[.\-_]test$/.test(stem);
}

for (const path of ['main.test.js', 'b-test.js', 'test/e.js', 'main.js', 'prices.spec.js']) {
  console.log(path, isTestFile(path));
}
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// True when node --test, run without file names, would run this file.
// path uses / between folders, such as 'src/prices.test.js'.
export function isTestFile(path) {
  return path.endsWith('.test.js');
}

for (const path of ['main.test.js', 'b-test.js', 'test/e.js', 'main.js', 'prices.spec.js']) {
  console.log(path, isTestFile(path));
}

main.test.js

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

test('names that end in .test, -test or _test count', () => {
  for (const path of ['main.test.js', 'src/prices.test.mjs', 'b-test.js', 'c_test.cjs', 'lib/g.test.ts', 'lib/my-test.ts']) {
    assert.equal(isTestFile(path), true, `isTestFile('${path}') should be true`);
  }
});

test('test.js and names that start with test- count', () => {
  for (const path of ['test.js', 'src/test.mjs', 'test-d.js']) {
    assert.equal(isTestFile(path), true, `isTestFile('${path}') should be true`);
  }
});

test('every script inside a folder named test counts', () => {
  for (const path of ['test/e.js', 'test/unit/f.js']) {
    assert.equal(isTestFile(path), true, `isTestFile('${path}') should be true`);
  }
});

test('other names do not count', () => {
  for (const path of ['main.js', 'prices.spec.js', 'tests.js', 'latest.js', 'testing.js', 'contest/x.js']) {
    assert.equal(isTestFile(path), false, `isTestFile('${path}') should be false`);
  }
});

test('only script extensions count, even in a folder named test', () => {
  assert.equal(isTestFile('test/data.json'), false, "isTestFile('test/data.json') should be false");
  assert.equal(isTestFile('main.test.txt'), false, "isTestFile('main.test.txt') should be false");
});

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 2

Count like the summary

Write summarize(results). Each result is {name, ok}, plus skip: true or todo: true when the test was marked so. Return {tests, pass, fail, skipped, todo, exitCode}. A skipped test counts only under skipped, a TODO test only under todo, whether it passed or not. The others count under pass or fail. exitCode is 1 when fail is above 0, otherwise 0. tests counts every result.

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

    Count skipped and todo first with filter: results.filter((r) => r.skip).length.

  2. Hint 2

    The plain tests are those with neither mark: results.filter((r) => !r.skip && !r.todo). Split them by ok.

  3. Hint 3

    exitCode depends only on the plain failures: fail > 0 ? 1 : 0.

Show a solution

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

// Counts results the way node --test does in its summary.
// Each result is {name, ok}, with skip: true or todo: true when the test was marked so.
export function summarize(results) {
  const skipped = results.filter((r) => r.skip).length;
  const todo = results.filter((r) => r.todo && !r.skip).length;
  const plain = results.filter((r) => !r.skip && !r.todo);
  const pass = plain.filter((r) => r.ok).length;
  const fail = plain.length - pass;
  return {tests: results.length, pass, fail, skipped, todo, exitCode: fail > 0 ? 1 : 0};
}

console.log(summarize([
  {name: '3.50 is 350 cents', ok: true},
  {name: 'negative prices', ok: true, skip: true},
  {name: 'prices with a comma', ok: false, todo: true}
]));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Counts results the way node --test does in its summary.
// Each result is {name, ok}, with skip: true or todo: true when the test was marked so.
export function summarize(results) {
  const pass = results.filter((r) => r.ok).length;
  return {tests: results.length, pass, fail: results.length - pass, skipped: 0, todo: 0, exitCode: 0};
}

console.log(summarize([
  {name: '3.50 is 350 cents', ok: true},
  {name: 'negative prices', ok: true, skip: true},
  {name: 'prices with a comma', ok: false, todo: true}
]));

main.test.js

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

test('plain tests count as pass or fail', () => {
  const got = summarize([{name: 'a', ok: true}, {name: 'b', ok: false}, {name: 'c', ok: true}]);
  assert.deepEqual(got, {tests: 3, pass: 2, fail: 1, skipped: 0, todo: 0, exitCode: 1}, `summarize gave ${JSON.stringify(got)}`);
});

test('skipped tests count only as skipped', () => {
  const got = summarize([{name: 'a', ok: true}, {name: 'b', ok: true, skip: true}]);
  assert.deepEqual(got, {tests: 2, pass: 1, fail: 0, skipped: 1, todo: 0, exitCode: 0}, `summarize gave ${JSON.stringify(got)}`);
});

test('a failing TODO test does not fail the run', () => {
  const got = summarize([{name: 'a', ok: true}, {name: 'b', ok: false, todo: true}, {name: 'c', ok: true, todo: true}]);
  assert.deepEqual(got, {tests: 3, pass: 1, fail: 0, skipped: 0, todo: 2, exitCode: 0}, `summarize gave ${JSON.stringify(got)}`);
});

test('no results: everything 0, exit code 0', () => {
  const got = summarize([]);
  assert.deepEqual(got, {tests: 0, pass: 0, fail: 0, skipped: 0, todo: 0, exitCode: 0}, `summarize([]) gave ${JSON.stringify(got)}`);
});

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

Putting the options after the function

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

function toCents(text) {
  if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new RangeError('not a price: ' + text);
  return Math.round(Number(text) * 100);
}

test('negative prices', () => {
  assert.equal(toCents('-1'), -100, "toCents('-1') should be -100");
}, {skip: true});

What Node.js prints

RangeError: not a price: -1

Why, and the fix

The order is test(name, options, fn). With {skip: true} after the function, node:test ignores it: the test runs, toCents('-1') throws, and the test fails. Move the options between the name and the function, test('negative prices', {skip: true}, () => { … }), or write test.skip('negative prices', () => { … }), which means the same.

Calling t.skip() without taking t

import {test} from 'node:test';

test('prices with a comma', () => {
  t.skip('commas come later');
});

What Node.js prints

ReferenceError: t is not defined

Why, and the fix

The docs' examples write test('…', (t) => { t.skip(); }): t is the test context, the first parameter of the test function. Copy the call without the parameter and t is an unknown name, so the test fails instead of being skipped. To skip a whole test, test.skip('prices with a comma', () => { … }) needs no t at all.

Using describe and it without importing them

import assert from 'node:assert/strict';

describe('toCents', () => {
  it('3.50 is 350 cents', () => {
    assert.equal(Math.round(3.5 * 100), 350, '3.50 should be 350 cents');
  });
});

What Node.js prints

ReferenceError: describe is not defined

Why, and the fix

Other test tools provide describe and it as globals; in Node.js they come from node:test, like test. Write import {describe, it} from 'node:test'; or keep to test() as this level does. The Intermediate level teaches describe, it and hooks such as beforeEach.

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

Which files node --test runs

node --test with no file names searches the folder and its subfolders. By default it runs files named like name.test.js, name-test.js, name_test.js, test-name.js or test.js, and every file inside a folder named test; .cjs and .mjs count too, and so do .ts, .cts and .mts. main.js, prices.spec.js and tests.js do not. When nothing matches, it runs 0 tests and exits with code 0. Name files and they run whatever their names: node --test prices.spec.js. A glob such as "**/*.spec.js" works too; put it in double quotes so that the shell does not expand it first. A name that matches no file prints Could not find and exits with code 1.

Read the output, choose a reporter

The default reporter, spec, prints ✔ or ✖ with the name of each test, then ℹ lines that count tests, suites, pass, fail, cancelled, skipped and todo, and the duration. After a failure it lists the failing tests with their errors, and the exit code is 1; otherwise it is 0. --test-reporter=tap prints the TAP format, TAP version 13 and ok 1 - name, which other tools read; --test-reporter=dot prints one character per test, . or X. The docs warn that the exact output may change between versions, so do not parse it. --test-name-pattern=cents runs only the tests whose name matches that regular expression; the others are left out of the output.

Skip, todo, only, watch, and a script

test.skip(name, fn) does not run fn and prints ﹣ name # SKIP. test.todo(name, fn) runs fn, but a failure shows as ⚠ name # TODO and does not change the exit code. {only: true} in a test's options means "run only this", and only with --test-only; without it, Node.js prints a note and runs every test. node --test --watch keeps running and re-runs the tests when a test file or a module it imports changes. The v24 docs mark this Stability 1, Experimental, while node --watch for programs is stable. Keep the commands in package.json, "test": "node --test" and "test:watch": "node --test --watch"; npm test -- --test-name-pattern=cents passes a flag on.

Sources

Last reviewed October 4, 2026