Testing & Tooling
Why tooling matters
Section titled “Why tooling matters”Writing code is only half the job. Knowing how to test, debug, measure, and configure your Node.js environment turns a working script into a maintainable, production-ready application.
Node 24 (the active LTS) ships a built-in test runner, type stripping for .ts files, a powerful inspector protocol, and perf_hooks for timing — meaning you can do a lot without installing anything.
Node release lines in 2026
Section titled “Node release lines in 2026”| Line | Status (Sep 2026) | Use it for |
|---|---|---|
| Node 22 | Maintenance LTS (until Apr 2027) | Existing production apps — require(esm), type stripping since 22.18 |
| Node 24 | Active LTS (LTS since Oct 2025) | New production apps — this course targets it |
| Node 26 | Current (LTS from Oct 2026) | Trying Temporal, Iterator.concat(), Undici 8 |
Node 18 and 20 are end-of-life. When a lesson says “Node 22+”, the feature exists on every supported line.
What this module covers
Section titled “What this module covers”| Lesson | Topic |
|---|---|
| Built-in Test Runner | node:test, node:assert, test(), describe/it, node --test |
| Debugging | node --inspect, Chrome DevTools, breakpoints, console methods |
| Performance | perf_hooks, performance.now(), PerformanceObserver, avoiding blocking |
| Ecosystem & Toolchain | TypeScript runners, ESLint/Prettier/Biome, nodemon, env files |
The toolchain at a glance
Section titled “The toolchain at a glance”flowchart TB
subgraph Project["Your Node.js Project"]
direction TB
Source["Source code (.ts / .js / .mjs)"]
Lint["Linter / Formatter"]
TSR["TypeScript runner"]
TestRunner["Test runner"]
Debugger["Debugger"]
Perf["Performance"]
Source --> Lint --> TSR --> TestRunner --> Debugger --> Perf
end
Lint --> LintTools["ESLint, Prettier, Biome"]
TSR --> TSRTools["node file.ts (built-in type stripping); tsx for enum etc."]
TestRunner --> TestTools["node:test (built-in) or Vitest / Jest"]
Debugger --> DebuggerTools["node --inspect + Chrome DevTools"]
Perf --> PerfTools["perf_hooks, PerformanceObserver"] Choosing what to install
Section titled “Choosing what to install”Node’s built-ins cover most basic needs. Here is a quick decision guide:
# Zero dependencies — use the built-in runnernode --test src/**/*.test.js
# TypeScript without a build step (Node 22.18+ / 24 — type stripping is on by default)node src/app.ts
# Run a package.json script without npm (Node 22+)node --run dev
# Faster HMR + watch modenpx tsx watch src/app.ts
# Lint + format in one tool (zero config)npx biome check --apply src/What’s already available, with zero installs
Section titled “What’s already available, with zero installs”Every capability this module covers is already inside the node binary you have. A quick self-check, run for real on Node 24:
Output (verified on Node 24)
node:test available: true
perf_hooks available: true
global fetch available: true
type stripping (.ts files) is on by default on Node 24 -- no flag neededMental Model
Section titled “Mental Model”Each lesson in this module answers one question about the same running process — think of it as four lenses pointed at the same node binary, not four separate tools:
flowchart TB Q1["Is my code correct?"] -->|"node:test + node:assert"| L1["Built-in Test Runner"] Q2["Why did it do that?"] -->|"--inspect, console.*, uncaughtException"| L2["Debugging"] Q3["Is it fast enough?"] -->|"perf_hooks, --cpu-prof, worker_threads"| L3["Performance"] Q4["How do I run/ship it?"] -->|"--env-file, --watch, node --run, Docker"| L4["Ecosystem & Toolchain"] L1 --> Ship["Code you can trust in production"] L2 --> Ship L3 --> Ship L4 --> Ship
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Built-in node:test / perf_hooks / --inspect | Zero install, zero config drift, works the same on every machine that has Node | Fewer conveniences than a mature ecosystem tool (no coverage UI, no watch-mode test filtering out of the box) |
| Third-party (Vitest, ESLint, pino, tsx) | Richer DX, plugin ecosystems, features Node hasn’t stabilized yet | A dependency to install, pin, audit, and keep updated — and a lockfile everyone must trust |
| Learning the built-ins first | Debugging AI-generated or unfamiliar code never depends on “which test framework does this repo use” | Slightly more typing for things a framework would give you as sugar (e.g. expect().toBe() vs assert.equal()) |
Common Misconceptions
Section titled “Common Misconceptions”- “You need Jest or Mocha to write real tests in Node” — fact:
node:testhas been built in since Node 18, and by Node 24 it covers mocking, fake timers, snapshots, and coverage without a single dependency. - “TypeScript needs a build step to run on Node” — fact: Node 24 strips type annotations at load time by default; you only need
tsx/ts-node/tscfor syntax that needs to be transformed (enum, decorators) rather than erased. - “perf_hooks / node —inspect / node:test are experimental or unstable” — fact: all three are stable APIs; only specific sub-features (coverage, some test-runner extras) still carry an
--experimental-flag, and each lesson calls those out explicitly.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a snippet reach for
jest/mocha/chaias a dependency for a tasknode:test+node:assertalready cover? That’s an unnecessary dependency, not a correctness bug — but it’s a signal the generated code wasn’t written with Node 24 in mind. - Does it add
ts-nodeor a bundler step for a plain.tsfile with only type annotations (noenum, no decorators)?node file.tsalready works. - Does it call a Node built-in behind an
--experimental-flag (test coverage, module mocks) without mentioning the flag or the warning it prints? Running the snippet as shown will crash or silently no-op.
🐛 Spot the bug
import { test } from 'node:test';import assert from 'node:assert/strict';
// package.json: "test": "node --test"test('creates a user', async function() {const user = await createUser({ name: 'Ada' });assert.equal(user.name, 'Ada');});
test.only('debugging this one locally', function() {assert.ok(true);});Show the answer
test.only marks exactly one test to run exclusively — but only when the runner is invoked with --test-only (or run({ only: true })). The package.json script above is plain node --test, so without --test-only both tests still run normally, .only is a no-op, and nothing looks wrong locally. The real danger is the opposite setup: a CI script that always passes --test-only. There, forgetting to remove test.only after a debugging session silently excludes every other test in the file from ever running again — and CI stays green while regressions in the skipped tests go uncaught. Search test files for .only before every commit, or lint for it.