Skip to main content

Migrating from Jest to the Native Node.js Test Runner (node:test)

 For years, Jest has been the default standard for testing in the JavaScript ecosystem. It is powerful, feature-rich, and essentially defined the "Developer Experience" for modern testing. However, that convenience comes at a significant cost: performance overhead and massive dependency trees.

As CI pipelines slow down and node_modules folders swell into the hundreds of megabytes, developers are looking for leaner alternatives. With the release of Node.js 20, the native node:test module became stable. It offers a standardized, zero-dependency way to run tests with performance that often outperforms Jest by an order of magnitude.

This guide details the architectural differences between the two, provides a rigorous migration path, and offers production-ready code examples for replacing Jest in a modern TypeScript environment.

The Root Cause: Why is Jest So Heavy?

To understand why migrating is valuable, we must analyze why Jest behaves the way it does. Jest is not merely a test runner; it is an entire override of the Node.js runtime environment.

The Virtualization Cost

When you run jest, it doesn't simply execute your JavaScript files. It creates a sandboxed environment for every test file. It implements its own CommonJS module resolution system to enable features like automatic mocking (jest.mock).

This means Jest intercepts every require or import call, transpiles code on the fly (often using Babel internally), and isolates globals. While this prevents test pollution, it consumes massive amounts of CPU and memory.

The Dependency Bloat

Jest ships with jsdom by default (unless configured otherwise). jsdom is a pure JavaScript implementation of the WHATWG DOM and HTML standards. While impressive, it is heavy. If you are writing backend services in Node.js, you are likely loading a browser environment you never use.

The Native Advantage: The node:test runner does not virtualize the module system. It runs your code directly in the V8 engine, utilizing the native ESM (ECMAScript Modules) loader. It shares the process (unless you explicitly use isolation techniques), resulting in near-instant startup times and significantly lower memory pressure.

Prerequisites

Before migrating, ensure your environment meets these requirements:

  1. Node.js v20.0.0 or higher (LTS recommended).
  2. TypeScript (This guide assumes a TS environment, as is standard in enterprise contexts).
  3. tsx (A robust TypeScript executor) to handle on-the-fly compilation, replacing ts-jest.

Step 1: Configuration Cleanup

First, we need to remove the heavy machinery. Uninstall Jest and its related types.

npm uninstall jest ts-jest @types/jest
npm install --save-dev tsx @types/node

Update your package.json scripts. We will replace the complex Jest configuration with a direct call to the Node binary using the glob pattern to find test files.

{
  "scripts": {
    "test": "node --import tsx --test \"src/**/*.test.ts\"",
    "test:watch": "node --import tsx --test --watch \"src/**/*.test.ts\""
  }
}

The --import tsx flag registers the TypeScript loader before running tests, eliminating the need for a jest.config.js file entirely.

Step 2: Syntax Migration (Assertions)

Jest introduces global variables (describeitexpect) into the environment. The native test runner requires explicit imports. This adheres to modern JavaScript best practices by avoiding global namespace pollution.

We also replace Jest's expect assertions with the native node:assert module.

The Legacy Code (Jest)

// user.service.test.ts (Jest)
describe('UserService', () => {
  it('should calculate user age', () => {
    const user = { birthYear: 1990 };
    expect(calculateAge(user)).toBe(34);
    expect(user).toEqual({ birthYear: 1990 });
  });
});

The Modern Code (Node Native)

// user.service.test.ts (Node Native)
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

describe('UserService', () => {
  it('should calculate user age', () => {
    const user = { birthYear: 1990 };
    
    // assert.equal checks strictly (===) when using 'node:assert/strict'
    assert.equal(calculateAge(user), 34);
    
    // assert.deepEqual checks structural equality
    assert.deepEqual(user, { birthYear: 1990 });
  });
});

Note: Always import from node:assert/strict. The standard node:assert performs loose equality (==) by default for legacy reasons, which can lead to false positives in tests.

Step 3: Migrating Mocks and Spies

This is the most technically challenging part of the migration. Jest's jest.fn() and jest.spyOn() are highly ergonomic. Node.js achieves the same result using node:test's mock object, but the syntax enforces strict ESM compatibility.

Scenario: Mocking a Database Dependency

Imagine we have a UserService that calls a Database class.

// src/database.ts
export class Database {
  async getUser(id: string) {
    return { id, name: 'Real User' };
  }
}

// src/user.service.ts
import { Database } from './database.js';

export class UserService {
  constructor(private db: Database) {}

  async find(id: string) {
    return this.db.getUser(id);
  }
}

The Legacy Approach (Jest)

In Jest, you might intercept the prototype or use automocking.

// Jest approach
import { UserService } from './user.service';
import { Database } from './database';

it('calls the db', async () => {
  const mockDb = new Database();
  const spy = jest.spyOn(mockDb, 'getUser').mockResolvedValue({ id: '1', name: 'Mock' });
  
  const service = new UserService(mockDb);
  await service.find('1');
  
  expect(spy).toHaveBeenCalledWith('1');
});

The Native Approach (node:test)

Node.js provides a mock utility to create spies and mock functions.

// src/user.service.test.ts
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { UserService } from './user.service.js';
import { Database } from './database.js';

describe('UserService', () => {
  it('calls the db correctly', async () => {
    // 1. Instantiate the real class
    const dbInstance = new Database();

    // 2. Create a spy on the method
    // mock.method(object, methodName, implementation)
    const getUserMock = mock.method(dbInstance, 'getUser', async () => {
      return { id: '1', name: 'Mock User' };
    });

    const service = new UserService(dbInstance);
    const result = await service.find('1');

    // 3. Assertions
    assert.deepEqual(result, { id: '1', name: 'Mock User' });
    
    // Check call count
    assert.equal(getUserMock.mock.callCount(), 1);
    
    // Check arguments of the first call
    assert.deepEqual(getUserMock.mock.calls[0].arguments, ['1']);
  });
});

Deep Dive: Handling Module Mocking

One area where Jest shines is jest.mock('./module'), which intercepts imports. Because Node.js adheres to strict ESM standards, you cannot magically intercept imports in the same way without loaders.

To keep your architecture clean and testable, rely on Dependency Injection (as shown in the UserService example above) rather than module interception. Passing dependencies into constructors makes node:test trivial to implement.

However, if you absolutely must mock a top-level import, usage of libraries like esmock or quibble is required, as Node native module mocking is still experimental/limited in scope compared to Jest's runtime manipulation.

Lifecycle Hooks Comparison

The lifecycle hooks are nearly identical, but strict naming is enforced.

FeatureJestNode.js Native
Suitedescribedescribe
Testit / testit / test
Setup (Once)beforeAllbefore
Setup (Each)beforeEachbeforeEach
Teardown (Each)afterEachafterEach
Teardown (Once)afterAllafter

Common Pitfalls and Edge Cases

1. Missing DOM APIs

If your tests rely on windowdocument, or localStorage (common in React logic/utils testing), they will fail. node:test is strictly a server-side runner.

Solution: Install global-jsdom.

npm install --save-dev global-jsdom

And import it at the top of your test file:

import 'global-jsdom/register';
import { describe, it } from 'node:test';
// Now window.location is available

2. Snapshot Testing

Jest users heavily rely on snapshots. Node 22.3.0+ supports snapshots natively, but in Node 20, functionality is limited.

Solution for Node 20+:

import assert from 'node:assert/strict';

// Available in newer Node versions, checks against a generated .snapshot file
assert.snapshot(actualOutput, 'Optional Message'); 

If you are on an older minor version of Node 20, stick to asserting against object literals or JSON strings.

3. Asynchronous Code

Jest occasionally allows returning a Promise without await in messy codebases. Node's runner expects standard async/await syntax. Ensure the function passed to it is marked async if you perform asynchronous operations.

it('fails if promise not awaited', async () => {
  await someAsyncOperation(); // Mandatory
});

Conclusion

Migrating to node:test moves your infrastructure closer to the standard JavaScript platform. You eliminate the complexity of the Jest adapter layer, reduce CI install times by removing massive dependencies, and gain improved execution speed.

While you lose some of the "batteries-included" magic of Jest—specifically regarding UI component testing and module-level mocking—the result is a codebase that is more transparent, follows standard architectural patterns (like Dependency Injection), and is future-proofed by the Node.js runtime itself.