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
| Aspect | Jest | Vitest |
|---|---|---|
| Foundation | Babel / ts-jest transform | Vite transform pipeline |
| ESM support | Retrofit, requires config | Native |
| TypeScript | Requires ts-jest or Babel preset | Out of the box |
| Mock namespace | jest | vi |
| Coverage default | Istanbul | V8 |
| Watch mode | jest --watch | Default (vitest) |
| Config file | jest.config.js or package.json | Reuses vite.config.* |
| NestJS support | Official | Not official |
Jest Setup Commands
| Step | Command |
|---|---|
| Install | npm install --save-dev jest |
| TypeScript | npm install --save-dev ts-jest @types/jest |
| Config | testEnvironment: "node" in package.json |
| Run | npx jest |
Vitest Setup Commands
| Step | Command |
|---|---|
| Install | npm install -D vitest |
| TypeScript | Nothing extra |
| Config | Optional vitest.config.js or reuse vite.config.js |
| Run | npx vitest (watch) / npx vitest run (CI) |
Mocking Equivalents
| Jest | Vitest |
|---|---|
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
Jest tests fail on process.env | testEnvironment is jsdom | Set to "node" |
| Jest ESM import fails | Dependency is ESM-only | transformIgnorePatterns or switch to Vitest |
| Vitest CI hangs | Default is watch mode | Use vitest run |
| Mock factory variable undefined | Vitest hoisting differs | Use vi.hoisted() |
| Express port conflict in tests | listen() in imported file | Separate 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
| Item | Value |
|---|---|
| Jest foundation | Babel/ts-jest transform |
| Vitest foundation | Vite transform pipeline |
| API compatibility | ~95% (namespace differs) |
| ESM support | Jest retrofit; Vitest native |
| TypeScript setup | Jest needs ts-jest; Vitest needs nothing |
| Coverage default | Jest: Istanbul; Vitest: V8 |
| Watch mode | Jest: --watch; Vitest: default |
| NestJS | Jest official |
| Vite projects | Vitest preferred |
Key takeaways:
- The test API is nearly identical.
describe,it,expect, andbeforeEachwork the same way in both. The migration is mostly a namespace change fromjesttovi. - 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 isjsdom, which does not have the correct Node.js globals .- Vitest defaults to watch mode. Use
vitest runin 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!