Jest: The Old Reliable That's Showing Its Age
Jest dominated Node.js testing for years, but it's starting to show cracks in 2025. The main problem? ESM support is still experimental. If you're using "type": "module"
in your package.json (and you should be), Jest will fight you every step of the way according to the official Jest ESM documentation.
The Jest GitHub repository shows thousands of ESM-related issues that have been open for years. The Jest configuration documentation will make you question your sanity with options like extensionsToTreatAsEsm
and moduleNameMapper
that shouldn't exist in 2025. Even the Jest testing framework guide admits ESM support is "experimental" after 4+ years.
Jest's Problems in 2025:
- ESM support is experimental and buggy - I wasted 3 days getting Jest to work with native ESM imports and almost quit web development
- Slow startup times - Takes 5-10 seconds to start running tests on medium-sized projects
- CommonJS assumptions everywhere - Mocking breaks when you use ES modules
- Heavy dependencies - Installs 50+ packages you didn't ask for
- Configuration hell - Need
babel.config.js
,jest.config.js
, and a degree in black magic
// Jest ESM configuration nightmare
// package.json
{
"type": "module",
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"jest": {
"preset": "jest-preset-es-modules",
"transform": {},
"extensionsToTreatAsEsm": [".js"],
"globals": {
"ts-jest": {
"useESM": true
}
}
}
}
// Still might not work
When Jest Still Makes Sense:
- Legacy projects already using CommonJS modules
- Large teams that need mature tooling and extensive documentation
- React projects where Jest is the de facto standard
- When you have time to fight configuration issues
- Enterprise environments where stability trumps developer experience
- Projects using Facebook's internal tools and patterns
Vitest: The Fast New Kid That Actually Works
Vitest was built by the Vite team specifically to solve Jest's problems. It's much faster than Jest in watch mode and handles ESM modules like they're not an exotic foreign concept. The Vitest documentation shows just how seamless the transition from Jest can be.
The Vitest GitHub repository shows active development and rapid issue resolution. Vitest configuration is minimal because it inherits from Vite's configuration, meaning you can reuse your existing build setup. The Vitest API is Jest-compatible by design, making migration straightforward according to their migration guide.
Vitest Advantages:
// Vitest just works with ESM
// No configuration needed
import { test, expect } from 'vitest'
import { sum } from '../src/math.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
Performance Reality Check:
- Jest: Startup is painfully slow - I've literally made coffee while waiting for Jest to boot up
- Vitest: Way faster - starts before you finish typing the command
- Node.js Native: Fastest startup but you get what you pay for feature-wise
Vitest's Sweet Spots:
- TypeScript projects with built-in support
- ESM modules that work without configuration
- Watch mode development that's blazing fast
- Vite-based frontends with shared configuration
- Monorepo projects using Vitest workspace
- Browser testing with WebDriver support
Vitest Gotchas:
// Vitest doesn't auto-mock like Jest
// You need to explicitly mock modules
import { vi } from 'vitest'
// Jest: automatically mocks everything
// Vitest: you have to ask nicely
vi.mock('./database.js', () => ({
connect: vi.fn(),
query: vi.fn()
}))
Node.js Native Test Runner: The Underdog That's Getting Better
Since Node.js 18, there's a built-in test runner that's fast, lightweight, and doesn't require any dependencies. It's basic but improving rapidly with each Node.js release. The Node.js testing documentation shows the current capabilities.
The Node.js test runner source code is actively maintained by the core team. Node.js 20 improvements added better coverage reporting, and Node.js 21 enhanced watch mode functionality.
Native Test Runner Benefits:
// test/user.test.js
import { test, describe } from 'node:test'
import assert from 'node:assert'
import { createUser } from '../src/user.js'
describe('User creation', () => {
test('creates user with valid data', () => {
const user = createUser({ name: 'Alice', email: 'alice@example.com' })
assert.strictEqual(user.name, 'Alice')
assert.strictEqual(user.email, 'alice@example.com')
})
test('throws error with invalid email', () => {
assert.throws(() => {
createUser({ name: 'Bob', email: 'invalid' })
}, /Invalid email/)
})
})
Running Native Tests:
## Run all tests
node --test
## Run specific test file
node --test test/user.test.js
## Watch mode (Node.js 20+)
node --test --watch
## Coverage (Node.js 20+)
node --test --experimental-test-coverage
Native Runner Limitations:
- No built-in mocking (yet)
- Limited assertion library
- No snapshot testing
- Smaller ecosystem compared to Jest/Vitest
My Recommendation for 2025
For New Projects:
Just use Vitest. Seriously. Stop overthinking it. Jest's ESM support will make you hate your life, and Node.js native runner is still too bare-bones unless you're building a simple CLI tool.
For Existing Projects:
- Don't migrate unless you're having actual problems
- Jest → Vitest migration is usually straightforward and worth the performance gains
- Jest → Native requires more work but eliminates dependencies
The Mocking Dilemma: How Much Is Too Much?
The biggest mistake I see in Node.js testing is the mocking everything approach. Tests end up testing mock implementations instead of real behavior.
Example of Over-Mocking:
// DON'T DO THIS - testing mocks, not logic
jest.mock('../database')
jest.mock('../email-service')
jest.mock('../payment-gateway')
jest.mock('../user-validator')
test('creates user', async () => {
// This test doesn't test anything real
const mockUser = { id: 1, name: 'Test' }
database.create.mockResolvedValue(mockUser)
emailService.sendWelcome.mockResolvedValue(true)
const result = await createUser({ name: 'Test' })
expect(result).toEqual(mockUser)
expect(emailService.sendWelcome).toHaveBeenCalled()
})
Better Approach - Strategic Mocking:
// Mock only external dependencies that you can't control
import { vi } from 'vitest'
// Mock the external email service, but not your own business logic
vi.mock('../lib/email-client', () => ({
sendEmail: vi.fn().mockResolvedValue({ messageId: 'test-123' })
}))
test('user creation with real validation logic', async () => {
// Use real validation, database operations with in-memory DB
const user = await createUser({
name: 'Alice Johnson',
email: 'alice@example.com'
})
// Test real business logic
expect(user.name).toBe('Alice Johnson')
expect(user.slug).toBe('alice-johnson')
expect(user.createdAt).toBeInstanceOf(Date)
// Verify external service was called correctly
expect(sendEmail).toHaveBeenCalledWith({
to: 'alice@example.com',
subject: 'Welcome to our platform'
})
})
Testing Patterns That Actually Work
1. The Test Pyramid - But Realistic
The classic test pyramid (lots of unit tests, some integration, few E2E) is good theory but often doesn't match reality. For Node.js applications, I've found this distribution works better:
- 50% Integration Tests - Test your API endpoints, database interactions, business logic together
- 30% Unit Tests - Complex algorithms, validation logic, utility functions
- 20% E2E Tests - Critical user flows, payment processing, authentication flows
2. Database Testing Without Pain
Don't mock your database. Use a real database with proper setup/teardown:
import { beforeAll, afterAll, beforeEach } from 'vitest'
import { Pool } from 'pg'
import { migrate } from '../src/database/migrate.js'
let db
let testContainer
beforeAll(async () => {
// Use testcontainers for realistic database testing
testContainer = await new PostgreSqlContainer()
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start()
db = new Pool({
connectionString: testContainer.getConnectionUri()
})
// Run migrations
await migrate(db)
})
beforeEach(async () => {
// Clean slate for each test
await db.query('TRUNCATE TABLE users, orders, products CASCADE')
})
afterAll(async () => {
await db.end()
await testContainer.stop()
})
3. Async Testing Without Tears
Node.js is async by nature. Your tests should be too:
test('async operations with proper error handling', async () => {
// Don't forget to await, or test will pass when it should fail
await expect(async () => {
await processPayment({ amount: -100 })
}).rejects.toThrow('Invalid amount')
// Test async operations properly
const result = await processPayment({ amount: 100 })
expect(result.status).toBe('completed')
})
// Test timeout behavior
test('handles slow operations', async () => {
// Set timeout for slow operations
vi.setConfig({ testTimeout: 10000 })
const slowOperation = processLargeFile('huge-file.csv')
const result = await slowOperation
expect(result.processedRows).toBeGreaterThan(0)
}, 10000) // 10 second timeout
4. Environment-Aware Testing
Your tests should work the same way locally and in CI:
// test/setup.js
import dotenv from 'dotenv'
// Load test environment variables
dotenv.config({ path: '.env.test' })
// Ensure test environment
if (process.env.NODE_ENV !== 'test') {
throw new Error('Tests must run with NODE_ENV=test')
}
// Mock external services in test environment
if (process.env.NODE_ENV === 'test') {
// Mock Stripe in tests
process.env.STRIPE_SECRET_KEY = 'sk_test_mock'
}
Half the teams I work with are still fighting Jest config from 2018 while the tooling moved on without them. I've seen codebases still wrestling with Jest ESM hell while Vitest developers are shipping features.
Stop chasing 100% coverage and test the shit that actually breaks. Like error handling. And edge cases. And the integration points where your beautiful microservices architecture falls apart.
After debugging production failures at 3am for the last time, trust me - test the scary stuff that keeps you awake at night, not the happy path functions that never fail.