| |

Node.js 72 🟢 Unit Testing Node.js Applications with Jest and Vitest

Jest and Vitest both provide the same core testing vocabulary—describe, it, expect, mocks, snapshots, and coverage—but they are built on fundamentally different foundations. Jest was created in the CommonJS era and has spent years retrofitting support for ES modules and TypeScript through Babel transforms and experimental flags . Vitest was built from the start on top of Vite, which means it treats ES modules and TypeScript as native citizens, reuses the project’s existing build pipeline, and runs tests through the same transform engine that serves the development server .

The practical consequence of this architectural difference is configuration burden. A Jest project with TypeScript and ESM dependencies typically requires babel-jest or ts-jest, a babel.config.js or tsconfig override, and sometimes --experimental-vm-modules to make imports work at all . A Vitest project in the same codebase requires npm install -D vitest and a test block in the existing vite.config.ts—or nothing at all if the defaults are acceptable . The trade-off is ecosystem maturity: Jest has broader plugin support, deeper integration with older frameworks, and is the official test runner for NestJS . Vitest is the correct default for Vite-based projects and for any Node.js codebase that uses ES modules natively.

Key point: Jest and Vitest share a nearly identical test API, but Jest requires separate configuration for TypeScript and ESM while Vitest reuses the Vite pipeline; choose Jest for existing CommonJS codebases and NestJS, and choose Vitest for Vite projects, ESM-native codebases, and any setup where avoiding a second transform pipeline matters.


Why the foundation determines the experience

The CommonJS retrofit problem. Jest was designed when require() was the only module system. Supporting import required adding a transform layer that converts ESM to CommonJS before Jest’s runtime can execute it. This layer is babel-jest or ts-jest, and it introduces a second compilation pipeline alongside whatever the application already uses . When a dependency ships as ESM-only, Jest’s transform may not process it correctly, and the fix is often transformIgnorePatterns configuration that is difficult to debug. The Xebia case study describes importing d3-delaunay—an ESM package—and spending hours on “mysterious configuration fixes” before switching to Vitest, where the import worked without any configuration .

The Vite reuse advantage. Vitest reads the project’s vite.config.* file and reuses its plugins, aliases, and transform settings . If the application already uses Vite for development, the test environment is the same environment. There is no second configuration file, no separate Babel preset, and no mismatch between how the code is transformed for the browser and how it is transformed for tests. The Vitest documentation frames this as the core rationale: “In a world where we have Vite providing support for the most common web tooling, Jest represents a duplication of complexity” .

The ESM-only future. Node.js 20+ has stable ESM support, and many modern packages ship as ESM-only . Jest’s ESM support has improved—Jest 30+ reduces the configuration burden—but it remains a retrofit. Vitest was designed for ESM first, and its handling of both ESM and CommonJS is seamless . For a new project in 2026, the default assumption should be ESM, and Vitest aligns with that assumption.

The performance difference. Vitest uses Vite’s transform pipeline and worker threads, which makes cold starts and re-runs faster than Jest’s Babel-based pipeline in projects that already use Vite . The benchmark differences depend on the project, but the architectural reason is clear: Vitest does not need to initialize a separate transform layer because it is using the one that is already running.


a. Setting up Jest for a Node.js application

Jest requires installation and, for anything beyond plain CommonJS, additional configuration. The minimal setup for a Node.js API with Express and Supertest is:

npm install --save-dev jest supertest @types/jest

The test script and Jest configuration go in package.json. The testEnvironment must be set to node—the default is jsdom, which is a browser environment that does not have process or the Node.js globals correctly .

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coveragePathIgnorePatterns": ["/node_modules/"],
    "testMatch": ["**/*.test.js"]
  }
}

The testEnvironment: "node" setting is the single most common source of confusion for developers coming from React. Without it, Jest runs tests in a simulated browser, and any code that touches process.env, fs, or http behaves incorrectly or fails with cryptic errors .

For TypeScript, Jest requires ts-jest or babel-jest with @babel/preset-typescript. The ts-jest route uses the TypeScript compiler directly; the Babel route uses Babel’s transform. Both add configuration and a dependency. The Jest documentation acknowledges that this is necessary because Jest does not understand TypeScript on its own .

A basic test file:

// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

The test function is global, as are expect, describe, and beforeEach. Jest’s globals are enabled by default, which is convenient but less explicit than Vitest’s default import style .


b. Setting up Vitest for a Node.js application

Vitest requires a single dependency for the core runner and no configuration for a standard Node.js project:

npm install -D vitest

The test script is added to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

The default vitest command starts in watch mode, which re-runs tests when files change. vitest run executes once and exits, which is the command for CI .

A test file in Vitest imports its functions explicitly:

// sum.test.js
import { expect, test } from 'vitest';
import { sum } from './sum.js';

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

The explicit imports are a deliberate choice. Vitest supports globals: true in its configuration, which makes describe, it, and expect global like Jest, but the documentation recommends explicit imports for TypeScript type safety . The globals: true option requires adding "types": ["vitest/globals"] to tsconfig.json if TypeScript is in use.

For TypeScript, Vitest requires no additional setup. Naming the file .test.ts instead of .test.js is sufficient. There is no ts-jest, no Babel preset, and no separate tsconfig for tests . This is the direct consequence of Vite’s transform pipeline handling TypeScript natively.


c. Mocking, coverage, and migration considerations

The mocking API is where the two libraries diverge syntactically. Jest uses jest.fn(), jest.spyOn(), jest.mock(), and jest.useFakeTimers(). Vitest uses vi.fn(), vi.spyOn(), vi.mock(), and vi.useFakeTimers() . The concepts are identical, and the jest-to-vitest codemod handles the mechanical rename. The migration guide reports that roughly 40% of vi.mock() calls with factory functions required manual intervention because Vitest’s hoisting rules differ from Jest’s. The fix is vi.hoisted(), which explicitly creates values available in the hoisted scope .

Coverage is built into both, but the providers differ. Vitest defaults to V8 coverage, which uses Node.js’s native coverage instrumentation and is significantly faster than Istanbul. Istanbul remains available for cases where V8’s instrumentation misreports branch coverage or when native addons are in the test path . Jest defaults to Istanbul through babel-plugin-istanbul, which is slower but more accurate for complex conditional expressions.

For CI, the commands differ only in the runner name. Jest uses jest --ci --coverage --maxWorkers=4. Vitest uses vitest run --coverage. Vitest automatically uses worker threads matching the available CPU cores, so the maxWorkers flag is not needed .

The migration decision depends on the codebase. A Vite-based application should use Vitest because it eliminates a redundant transform pipeline. A NestJS application should use Jest because NestJS officially supports Jest and its testing utilities are built around it . A plain Node.js API with CommonJS can use either; Jest’s ecosystem is larger, but Vitest’s setup is simpler and its ESM handling is more reliable for the future.


Complete Example Session

The following session builds the same test suite in both frameworks for an Express API with a repository dependency, demonstrating the configuration and mocking differences.

// ============================================
// PART 1: THE MODULE UNDER TEST
// ============================================
// user-service.js
class UserService {
  constructor(repo) {
    this.repo = repo;
  }

  async getUser(id) {
    const user = await this.repo.findById(id);
    if (!user) throw new Error('User not found');
    return user;
  }
}

module.exports = UserService;

// ============================================
// PART 2: JEST TEST
// ============================================
// user-service.test.js
const UserService = require('./user-service');

describe('UserService', () => {
  let service;
  let mockRepo;

  beforeEach(() => {
    mockRepo = {
      findById: jest.fn(),
    };
    service = new UserService(mockRepo);
  });

  it('returns the user when found', async () => {
    const user = { id: 1, name: 'Alice' };
    mockRepo.findById.mockResolvedValue(user);

    const result = await service.getUser(1);

    expect(result).toEqual(user);
    expect(mockRepo.findById).toHaveBeenCalledWith(1);
  });

  it('throws when the user does not exist', async () => {
    mockRepo.findById.mockResolvedValue(null);

    await expect(service.getUser(1)).rejects.toThrow('User not found');
  });
});

// ============================================
// PART 3: VITEST TEST (SAME LOGIC)
// ============================================
// user-service.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import UserService from './user-service.js';

describe('UserService', () => {
  let service;
  let mockRepo;

  beforeEach(() => {
    mockRepo = {
      findById: vi.fn(),
    };
    service = new UserService(mockRepo);
  });

  it('returns the user when found', async () => {
    const user = { id: 1, name: 'Alice' };
    mockRepo.findById.mockResolvedValue(user);

    const result = await service.getUser(1);

    expect(result).toEqual(user);
    expect(mockRepo.findById).toHaveBeenCalledWith(1);
  });

  it('throws when the user does not exist', async () => {
    mockRepo.findById.mockResolvedValue(null);

    await expect(service.getUser(1)).rejects.toThrow('User not found');
  });
});

// ============================================
// PART 4: JEST CONFIGURATION
// ============================================
// package.json (jest section)
// {
//   "testEnvironment": "node",
//   "testMatch": ["**/*.test.js"]
// }

// ============================================
// PART 5: VITEST CONFIGURATION
// ============================================
// vitest.config.js (optional; defaults work)
// import { defineConfig } from 'vitest/config';
// export default defineConfig({
//   test: {
//     environment: 'node',
//   },
// });

// ============================================
// PART 6: RUNNING THE TESTS
// ============================================
// Jest:
// npm test
// npx jest --coverage

// Vitest:
// npm test (watch mode)
// npx vitest run (single run)
// npx vitest run --coverage

The six parts show the module under test, the Jest test with jest.fn() and globals, the Vitest test with explicit imports and vi.fn(), the Jest configuration, the optional Vitest configuration, and the run commands. What the session demonstrates is that the test logic is identical—the difference is the namespace (jest vs vi), the import style, and the configuration burden. The Jest version requires testEnvironment: "node"; the Vitest version works with defaults.


Quick Reference

Framework Comparison

AspectJestVitest
FoundationBabel / ts-jest transformVite transform pipeline
ESM supportRetrofit, requires configNative
TypeScriptRequires ts-jest or Babel presetOut of the box
Mock namespacejestvi
Coverage defaultIstanbulV8
Watch modejest --watchDefault (vitest)
Config filejest.config.js or package.jsonReuses vite.config.*
NestJS supportOfficialNot official

Jest Setup Commands

StepCommand
Installnpm install --save-dev jest
TypeScriptnpm install --save-dev ts-jest @types/jest
ConfigtestEnvironment: "node" in package.json
Runnpx jest

Vitest Setup Commands

StepCommand
Installnpm install -D vitest
TypeScriptNothing extra
ConfigOptional vitest.config.js or reuse vite.config.js
Runnpx vitest (watch) / npx vitest run (CI)

Mocking Equivalents

JestVitest
jest.fn()vi.fn()
jest.spyOn()vi.spyOn()
jest.mock()vi.mock()
jest.useFakeTimers()vi.useFakeTimers()
jest.clearAllMocks()vi.clearAllMocks()

Best Practices

✅ Do This:

// Jest: set node environment
"jest": { "testEnvironment": "node" }

// Vitest: import explicitly
import { describe, it, expect, vi } from 'vitest';

// Both: separate app from server
// app.js exports app, server.js calls listen()

❌ Don’t Do This:

// Jest: leave testEnvironment as jsdom for a Node API
// Vitest: forget to use vitest run in CI (default is watch)
// Both: mock the module under test
// Both: import app from server.js (binds a port)

Common Pitfalls

PitfallWhy It HappensFix
Jest tests fail on process.envtestEnvironment is jsdomSet to "node"
Jest ESM import failsDependency is ESM-onlytransformIgnorePatterns or switch to Vitest
Vitest CI hangsDefault is watch modeUse vitest run
Mock factory variable undefinedVitest hoisting differsUse vi.hoisted()
Express port conflict in testslisten() in imported fileSeparate app.js and server.js

Real-World Examples

1. Jest basic test

test('adds', () => { expect(1 + 1).toBe(2); });

2. Vitest basic test

import { test, expect } from 'vitest';
test('adds', () => { expect(1 + 1).toBe(2); });

3. Jest mock

const fn = jest.fn().mockResolvedValue({ id: 1 });

4. Vitest mock

const fn = vi.fn().mockResolvedValue({ id: 1 });

5. Jest coverage

npx jest --coverage

6. Vitest coverage

npx vitest run --coverage

7. Jest TypeScript config

// jest.config.js
module.exports = { preset: 'ts-jest', testEnvironment: 'node' };

8. Vitest TypeScript

// No config needed for .test.ts

9. Jest CI command

npx jest --ci --coverage

10. Vitest CI command

npx vitest run --coverage

Visual

Transform Pipelines

┌──────────────────────────────────────────────┐
│  JEST                                        │
│  Source → babel-jest/ts-jest → Jest runtime  │
│  Separate pipeline from app build            │
│  ESM → CJS conversion required               │
│                                              │
│  VITEST                                      │
│  Source → Vite transform → Vitest runtime    │
│  Same pipeline as dev server                 │
│  Native ESM and TypeScript                   │
└──────────────────────────────────────────────┘

API Compatibility

┌──────────────────────────────────────────────┐
│  describe / it / expect  → identical         │
│  beforeEach / afterEach  → identical         │
│  jest.fn()    → vi.fn()                      │
│  jest.mock()  → vi.mock()                    │
│  jest.spyOn() → vi.spyOn()                   │
└──────────────────────────────────────────────┘

Decision Flow

┌──────────────────────────────────────────────┐
│  Is the project Vite-based?                  │
│  ├─ YES → Vitest                             │
│  └─ NO → Is it NestJS?                       │
│          ├─ YES → Jest (official)            │
│          └─ NO → ESM-only dependencies?      │
│                  ├─ YES → Vitest             │
│                  └─ NO → Either (Jest mature)│
└──────────────────────────────────────────────┘

Configuration Burden

┌──────────────────────────────────────────────┐
│  JEST + TypeScript + ESM                     │
│  - jest, ts-jest, @types/jest                │
│  - jest.config.js with testEnvironment       │
│  - babel.config.js or tsconfig               │
│  - transformIgnorePatterns for ESM deps      │
│                                              │
│  VITEST + TypeScript + ESM                   │
│  - vitest                                    │
│  - (optional) test block in vite.config      │
└──────────────────────────────────────────────┘

Summary

ItemValue
Jest foundationBabel/ts-jest transform
Vitest foundationVite transform pipeline
API compatibility~95% (namespace differs)
ESM supportJest retrofit; Vitest native
TypeScript setupJest needs ts-jest; Vitest needs nothing
Coverage defaultJest: Istanbul; Vitest: V8
Watch modeJest: --watch; Vitest: default
NestJSJest official
Vite projectsVitest preferred

Key takeaways:

  • The test API is nearly identical. describe, it, expect, and beforeEach work the same way in both. The migration is mostly a namespace change from jest to vi .
  • Jest is a CommonJS-era tool that has been retrofitted for ESM. TypeScript and ESM-only dependencies require extra configuration, and some cases remain difficult to debug .
  • Vitest reuses the Vite pipeline. If the project uses Vite, there is no second transform configuration to maintain, and TypeScript works without ts-jest .
  • testEnvironment: "node" is mandatory for Jest with Node.js APIs. The default is jsdom, which does not have the correct Node.js globals .
  • Vitest defaults to watch mode. Use vitest run in CI and scripts that need a single execution .
  • Jest remains the right choice for NestJS and existing CommonJS codebases. The ecosystem is larger, the plugin support is broader, and NestJS officially supports it .
  • Vitest is the right choice for Vite projects and ESM-native codebases. The configuration burden is lower, the ESM handling is reliable, and the speed is better in projects that already use Vite .

Remember: Jest and Vitest are not competitors in a winner-take-all sense. They are two implementations of the same testing vocabulary, optimized for different module systems and build pipelines. If your project is Vite-based or ESM-native, Vitest removes a layer of configuration that Jest requires. If your project is NestJS or an older CommonJS codebase with an established Jest setup, migrating for its own sake is not worth the disruption. The test code itself is nearly portable—the decision is about the foundation underneath it, and the foundation determines how much configuration you maintain and how reliably your tests run in the same environment as your application.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!