Skip to main content
  1. Blog/

Node.js 20: The Built-in Test Runner and Permission Model Change the Game

·1141 words·6 mins
Osmond van Hemert
Author
Osmond van Hemert
JavaScript & Node.js - This article is part of a series.
Part : This Article

Node.js 20 entered LTS preparation in April, and after a few months of running it in development environments, I’m increasingly convinced this is one of the most significant Node releases in years. Not because of any single headline feature, but because two additions — the stable test runner and the experimental permission model — signal a fundamental shift in how the Node.js project thinks about the runtime’s role.

For a platform that famously relied on the ecosystem for almost everything, Node.js building these capabilities into the core is a statement. And having used both extensively, I think they got it right.

The Built-in Test Runner Grows Up
#

Node’s test runner module (node:test) was introduced as experimental in Node 18. In Node 20, it’s been promoted to stable, and the improvements since its initial release are substantial.

If you’ve been writing Node.js for any length of time, you’ve had the “which test framework” conversation. Jest, Mocha, Vitest, AVA, tap — the ecosystem is rich but fragmented. Every project makes a different choice, every team has preferences, and switching between projects means context-switching between test APIs.

The built-in test runner doesn’t try to replace these frameworks entirely, but it provides a solid foundation that requires zero dependencies:

import { describe, it } from 'node:test';
import assert from 'node:assert';

describe('user service', () => {
  it('should create a user with valid input', async () => {
    const user = await createUser({ name: 'Test', email: '[email protected]' });
    assert.strictEqual(user.name, 'Test');
    assert.ok(user.id);
  });

  it('should reject invalid email', async () => {
    await assert.rejects(
      () => createUser({ name: 'Test', email: 'invalid' }),
      { code: 'VALIDATION_ERROR' }
    );
  });
});

The API will feel familiar to anyone who’s used Mocha or Node’s tap. But the details matter:

Subtests and nesting work naturally with describe and it blocks. Concurrent test execution is supported out of the box — tests within a describe block can run in parallel with the { concurrency: true } option. Test hooks (before, after, beforeEach, afterEach) work as expected. Mocking is built in via node:test’s mock object, covering timers, functions, and modules.

The mock capabilities are particularly welcome. Previously, mocking in Node tests meant pulling in sinon, proxyquire, jest.mock(), or similar. Having it built into the runtime means fewer dependencies and fewer compatibility issues.

I’ve been migrating a medium-sized internal API project from Jest to node:test over the past month. The test suite — about 300 tests — moved over with surprisingly little friction. The main gaps I’ve hit are snapshot testing (not yet supported) and the rich matcher library Jest provides. For the matchers, node:assert is more verbose but perfectly functional. For snapshots, I’m holding off on those test files for now.

The Permission Model: Defense in Depth for Node
#

The more quietly revolutionary feature in Node 20 is the experimental permission model. Enabled with the --experimental-permission flag, it lets you restrict what a Node.js process can do:

node --experimental-permission --allow-fs-read=/app/config --allow-fs-write=/app/logs app.js

This restricts the process to reading only from /app/config and writing only to /app/logs. Any attempt to access other paths, spawn child processes, or use worker threads throws an ERR_ACCESS_DENIED error.

The available permission flags include:

  • --allow-fs-read and --allow-fs-write for filesystem access
  • --allow-child-process for spawning processes
  • --allow-worker for worker threads

If you’re thinking “this sounds like Deno’s permission model,” you’re right. Deno pioneered this approach with its secure-by-default philosophy, requiring explicit permissions for file access, network access, and environment variables. Node is following suit, though the implementation differs.

Why does this matter? Because the Node.js ecosystem’s biggest security liability has always been its dependency chain. A typical Node project pulls in hundreds or thousands of transitive dependencies. Any one of them could contain malicious code that reads your filesystem, exfiltrates environment variables, or spawns processes.

The permission model doesn’t eliminate this risk — a compromised dependency within the allowed paths can still cause damage — but it significantly reduces the blast radius. If your application only needs to read from its config directory and write to its log directory, the permission model ensures that a rogue postinstall script or compromised dependency can’t read your SSH keys or write to arbitrary system paths.

Practical Implications for Production
#

For production deployments, combining the permission model with container security creates a robust defense-in-depth strategy:

  1. Container-level restrictions (read-only filesystem, dropped capabilities, non-root user)
  2. Node permission model (restrict file access to specific paths, disable child process spawning)
  3. Application-level validation (input sanitization, output encoding)

Each layer catches different classes of attacks. The Node permission model is particularly valuable for catching supply chain attacks that container security alone would miss — a dependency reading process.env to exfiltrate secrets, for example, could be blocked if you restrict which environment variables are accessible (though that granularity isn’t in the current implementation yet).

I’ve been running a staging environment with permissions enabled for the past six weeks. The initial setup required mapping out exactly which filesystem paths the application needs — which, honestly, is an exercise every team should do regardless. We discovered three dependencies that were writing temp files to unexpected locations and one that was reading /etc/hosts for no apparent reason. The permission model forced a security audit we should have done ages ago.

The Broader Trend
#

Node 20 reflects a maturing ecosystem. The built-in test runner reduces dependency on external tooling for a fundamental development activity. The permission model acknowledges that security can’t be an afterthought in a runtime that powers millions of production applications.

Both features also show Node.js learning from its competition. Deno’s security model and built-in tooling clearly influenced these additions. Bun’s focus on developer experience and performance is pushing Node to improve its own story. Competition in the JavaScript runtime space is producing better outcomes for everyone.

My Take
#

I’ve been writing Node.js since the 0.x days, and the trajectory of the project has been remarkable. From a scrappy runtime that relied entirely on npm for everything to a platform that includes testing, permissions, HTTP/2, worker threads, and diagnostic tools — Node has grown into a genuinely mature server-side runtime.

The test runner won’t replace Jest or Vitest for every project — those tools have richer ecosystems and more features. But for new projects, libraries, and smaller services, starting with node:test means one fewer dependency to manage, one fewer configuration to maintain, and one fewer tool to keep updated.

The permission model is the feature I’m most excited about long-term. It’s experimental today, but if the Node project continues to expand its scope — adding network permission controls, environment variable restrictions, and finer-grained filesystem policies — it could fundamentally improve the security story for Node.js applications.

Node 20 deserves more attention than it’s getting. Sometimes the most important features aren’t the flashiest.

This post is part of my Developer Landscape series, tracking the tools and platforms that shape modern software development.

JavaScript & Node.js - This article is part of a series.
Part : This Article