Testing with the Built-in Test Runner

By Syncfusion®, Inc. · Last updated

Why tests matter, unit and integration tests, Arrange-Act-Assert, node:test with test, describe and it, running tests with node --test, test file discovery, subtests, async tests, skip, todo and only, timeouts and reporters, node:assert strict mode, hooks, mocking functions, methods and timers, testing file-system code, EventEmitters, streams, HTTP servers and CLI tools, test coverage, watch mode, and how node:test compares with Vitest and Jest.

Testing with the Built-in Test Runner

Test = a small program that checks that your code does what it should.

  • You write the code once, and the tests check it again and again.
  • Node.js has a test runner built in: node:test, with checks from node:assert.
  • No extra package is needed.

Simple lesson: a test is code that checks your code, so you do not have to check by hand.

A Real-World Example

Think of the exam answer key.

  • The teacher writes the right answers once.
  • Every paper is checked against the key.
  • If a paper says 2 + 2 = 5, the key catches it at once.
  • Next year, the same key checks new papers in minutes.

Tests are the answer key for your code.

What You Will Learn

In this lesson, you will learn:

  1. Why tests matter, and the Arrange-Act-Assert pattern
  2. test(), describe() and it(), and running node --test
  3. Subtests, async tests, skip, todo, only and timeouts
  4. Assertions with node:assert/strict
  5. Hooks: before, after, beforeEach, afterEach
  6. Mocks for functions, methods and timers
  7. Testing files, events, streams, HTTP servers and CLI tools
  8. Coverage, watch mode and isolation
  9. node:test compared with Vitest and Jest

Testing Fundamentals

  • Why test? You change code without fear, because tests tell you at once if something broke.
  • Unit test = checks one small piece, such as one function, on its own.
  • Integration test = checks several pieces working together, such as a server and its routes.
  • Arrange-Act-Assert = the three steps inside a test:

    1. Arrange = prepare the data.
    2. Act = call the code.
    3. Assert = check the result.

Simple lesson: prepare, call, check. Every good test follows these three steps.

Animation: the test cycle. Write a failing test, make it pass, then clean up the code 1. Redthe test fails 2. Greenfix code, test passes 3. Clean uptests still pass

node --test runs every *.test file and reports each result

Red, green, clean up. Then repeat for the next small feature.

node:test Basics

  • test(name, fn) = one test. It passes if fn finishes without throwing.
  • describe(name, fn) = a group of tests. it = another name for test, used inside groups.
  • node --test = find test files and run them.
  • Test files are found by name, for example grades.test.js, grades-test.js, test.js and every file in a test/ folder.

Click ▶ Run. The lab runs node --test.

  • ✔ = a passing test. ▶ = a group, with its tests indented under it.
  • The summary counts tests, groups (suites), passes and failures.
  • Exit code = 0 when every test passes, 1 when any test fails. Build servers use this.

When you fix a bug, write the test first. Watch it fail, fix the code, and watch it pass.

A Failing Test

  • 450 !== 440 = actual result first, expected value second.
  • The test found a bug in fees.ts. Change 0.125 to 0.1, and run again.

More Test Options

  • Subtests = t.test(name, fn) inside a test. Await them.
  • Async tests = make the function async. The test waits for it.
  • skip = do not run this test now: test.skip(...) or { skip: true }.
  • todo = a test you plan to finish later. It runs, but a failure does not count.
  • only = run only the marked tests, with node --test --test-only.
  • Timeouts = { timeout: 1000 } fails a test that takes longer than 1 second.
  • Reporters = the output style. --test-reporter=spec is the readable tree. tap and dot also exist.

Simple lesson: name each test by what it checks, and run them all with node --test.

Assertions

Import the strict version: import assert from "node:assert/strict".

  • equal(a, b) = same value, using ===.
  • deepStrictEqual(a, b) = same contents for objects and arrays.
  • throws(fn, Error) = the function must throw.
  • rejects(promise, /text/) = the promise must fail.
  • match(text, /pattern/) = the text must match.
  • ok(value) = the value must be truthy.

Simple lesson: use equal for values, deepStrictEqual for objects, and throws or rejects for errors.

Hooks

Hook = code that runs around your tests, to prepare and clean up.

  • before(fn) = once, before all tests in the group.
  • after(fn) = once, after all tests in the group.
  • beforeEach(fn) = before every test. Use it to give each test fresh data.
  • afterEach(fn) = after every test.

Simple lesson: use beforeEach so tests never depend on each other.

Mocking

Mock = a fake stand-in for a real function, method or timer, so a test controls it and watches it.

  • mock.fn(impl) = a fake function that records every call in fn.mock.calls.
  • mock.method(obj, "name", impl) = replace one method on an object.
  • mock.timers = fake time. enable(), then tick(ms) moves time forward at once.
  • Restore = m.mock.restore() or mock.restoreAll() puts the real thing back. Inside a test, t.mock restores by itself.
  • Module mocking = t.mock.module(...) replaces a whole imported module. It needs an extra flag (--experimental-test-module-mocks).

Mock only the slow or costly parts. If you mock everything, the test checks nothing real.

Simple lesson: mocks replace slow, costly or random parts, so tests stay fast and sure.

Testing Node.js Code

  • File-system code = work inside a fresh temp folder from fs.mkdtemp, and delete it after.
  • EventEmitters = await once(emitter, "event") and check the arguments.
  • Streams = collect the output with await stream.toArray(), then compare.
  • HTTP servers = listen(0) picks a free port. Call it with fetch, then close() the server.
  • CLI tools = run them with execFile and check stdout and the exit code.

Testing a CLI tool runs a real child process. Here it is with real Node.js:

// main.mjs: a test that runs a CLI and checks its output
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const run = promisify(execFile);

test("the CLI greets by name", async () => {
  const { stdout } = await run(process.execPath, ["-e", "console.log('Hello, ' + process.argv[1])", "Asha"]);
  assert.equal(stdout.trim(), "Hello, Asha");
});

Output:

TAP version 13
# Subtest: the CLI greets by name
ok 1 - the CLI greets by name
  ---
  duration_ms: 57.342021
  type: 'test'
  ...
1..1
# tests 1
# suites 0
# pass 1
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 61.696107

Simple lesson: use temp folders, once, toArray, port 0 and execFile to test real Node.js code.

Coverage and Watch Mode

  • Coverage = which lines your tests actually ran. node --test --experimental-test-coverage prints a table.
  • Watch mode = node --test --watch runs the tests again whenever you save a file.
  • Isolation = each test file runs in its own process by default, so files cannot disturb each other.
  • Concurrency = files run in parallel. --test-concurrency=1 runs them one at a time.

Add "test": "node --test" to package.json, so npm test runs your tests.

grades.js

export function grade(mark) {
  if (mark >= 90) return "A";
  if (mark >= 35) return "Pass";
  return "Fail";
}

grades.test.js

import { test } from "node:test";
import assert from "node:assert/strict";
import { grade } from "./grades.js";

test("A for 95", () => assert.equal(grade(95), "A"));

package.json

{ "type": "module" }
$ node --test --experimental-test-coverage
ℹ start of coverage report
ℹ ---------------------------------------------------------------
ℹ file           | line % | branch % | funcs % | uncovered lines
ℹ ---------------------------------------------------------------
ℹ grades.js      |  60.00 |    66.67 |  100.00 | 3-4
ℹ grades.test.js | 100.00 |   100.00 |  100.00 | 
ℹ ---------------------------------------------------------------
ℹ all files      |  80.00 |    80.00 |  100.00 | 
  • The output above shows only the coverage table. The test lines come before it.
  • The tests ran only some lines of grades.js. The "Pass" and "Fail" lines were never checked.

node:test vs Vitest vs Jest

  • node:test = built in, no install, fast to start. Good for Node.js libraries, tools and APIs.
  • Vitest = popular with Vite, React and Vue projects. Shares Vite's settings (see Testing Vue Apps).
  • Jest = older and very widely used, with many plugins.
  • The ideas are the same in all three: describe, it, assertions, hooks and mocks.

Simple lesson: measure coverage to find untested code, and pick the runner your project already uses.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
assert.equal on two objectsIt compares contentsUse deepStrictEqualThe test fails though the data is the same
Not awaiting an async assertionThe test waits anywayawait assert.rejects(...)The test passes before the check runs
Tests that share dataOrder does not matterReset data in beforeEachTests pass alone but fail together
Calling real email or SMS servicesTests should be fully realMock themSlow, costly, flaky tests
Waiting for real timersTests must wait for real timeUse mock.timers and tickSlow tests
A fixed port like 3000 in testsThe port is always freeUse listen(0)EADDRINUSE when tests run in parallel
Leaving only in the codeIt is just for nowRemove it before committingMost tests silently stop running

TL;DR

  1. Test = code that checks code. Arrange, Act, Assert.
  2. test, describe, it from node:test. Run with node --test.
  3. Exit code = 0 all pass, 1 any fail.
  4. skip, todo, only, timeout = control which tests run.
  5. Assertions = equal, deepStrictEqual, throws, rejects, match.
  6. Hooks = before, after, beforeEach, afterEach.
  7. Mocks = mock.fn, mock.method, mock.timers.
  8. Node.js code = temp folders, once, toArray, port 0, execFile.
  9. Coverage = --experimental-test-coverage. Watch = --watch.

Check Yourself

Exercises

What to Remember

  • A test arranges data, acts, and asserts the result.
  • node --test finds and runs test files. Exit code 0 means all passed.
  • Use deepStrictEqual for objects, and throws or rejects for errors.
  • Reset shared data in beforeEach.
  • Mock slow, costly or timed parts with mock.fn, mock.method and mock.timers.
  • Test servers on port 0, and measure coverage to find untested code.

Next: Debugging and Profiling. You will find bugs with the inspector and find slow and leaky code.