Node.js Testing: Tools, Patterns, and Best Practices
Node.js has excellent testing support in 2026. The built-in node:test module handles most use cases without extra dependencies. For richer tooling, Jest and Vitest are the top choices. This guide covers everything: picking the right tool, writing unit and integration tests, mocking modules, testing async code, and setting up CI.
Key Takeaways
Node.js has a built-in test runner since v18. node:test and node:assert are stable, fast, and require zero dependencies. For simple projects or CLI tools, start here before reaching for Jest.
Vitest is the best choice for modern projects. It's Jest-compatible, but 10-20x faster because it uses Vite's module system. If you're building a new project in 2026, use Vitest.
Test isolation matters. Each test should set up and tear down its own state. Don't rely on test execution order. Use beforeEach/afterEach for database cleanup, mock resets, and server shutdown.
Mock at the right level. Mock external dependencies (HTTP clients, databases, file system), not your own code. Mocking your own functions couples tests to implementation details.
Test the behavior, not the implementation. Write tests that describe what your code does, not how it does it. A refactor shouldn't break tests if the behavior is unchanged.
Choosing a Node.js Testing Tool
Node.js has several solid options:
| Tool | When to Use |
|---|---|
node:test |
Simple projects, no build step, Node 18+ |
| Vitest | TypeScript, Vite-based projects, fastest option |
| Jest | Large teams, React/Vue frontends, rich ecosystem |
| Mocha | Legacy projects, needs flexible plugin system |
Our recommendation for new projects in 2026: Vitest. It's Jest-compatible so you can migrate existing Jest tests, but it starts faster, runs faster, and has first-class TypeScript support.
Option 1: Node.js Built-in Test Runner
No installation needed — just Node 18+.
// math.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { add, divide } from './math.js'
test('add returns correct sum', () => {
assert.equal(add(2, 3), 5)
assert.equal(add(-1, 1), 0)
assert.equal(add(0, 0), 0)
})
test('divide by zero throws', () => {
assert.throws(
() => divide(10, 0),
{ message: 'Cannot divide by zero' }
)
})
Run:
node --test math.test.js
<span class="hljs-comment"># or run all test files:
node --<span class="hljs-built_in">test
Async Tests
import { test } from 'node:test'
import assert from 'node:assert/strict'
test('fetch user returns correct data', async () => {
const user = await fetchUser(123)
assert.equal(user.id, 123)
assert.equal(user.name, 'Alice')
})
Subtests and Grouping
test('user service', async t => {
await t.test('creates user with valid data', async () => {
const user = await createUser({ name: 'Alice', email: 'alice@example.com' })
assert.ok(user.id)
assert.equal(user.name, 'Alice')
})
await t.test('rejects duplicate email', async () => {
await createUser({ email: 'bob@example.com' })
await assert.rejects(
() => createUser({ email: 'bob@example.com' }),
{ code: 'DUPLICATE_EMAIL' }
)
})
})
Mocking with node:test
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
test('sends welcome email after registration', async () => {
// Mock the email function
const sendEmail = mock.fn(() => Promise.resolve())
await registerUser({ email: 'alice@example.com' }, { sendEmail })
assert.equal(sendEmail.mock.calls.length, 1)
assert.equal(sendEmail.mock.calls[0].arguments[0], 'alice@example.com')
})
Option 2: Vitest (Recommended)
npm install --save-dev vitest
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Vitest uses Jest-compatible syntax, so if you know Jest, you already know Vitest.
Option 3: Jest
npm install --save-dev jest
# For TypeScript:
npm install --save-dev jest @types/jest ts-jest
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"transform": {
"^.+\\.ts$": "ts-jest"
}
}
}
Writing Unit Tests
Unit tests test a single function or module in isolation.
// src/utils/validate.js
export function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return re.test(email)
}
export function validatePassword(password) {
if (password.length < 8) {
return { valid: false, reason: 'Too short' }
}
if (!/[A-Z]/.test(password)) {
return { valid: false, reason: 'Needs uppercase letter' }
}
if (!/[0-9]/.test(password)) {
return { valid: false, reason: 'Needs a number' }
}
return { valid: true }
}
// src/utils/validate.test.js
import { describe, it, expect } from 'vitest'
import { validateEmail, validatePassword } from './validate.js'
describe('validateEmail', () => {
it('accepts valid email addresses', () => {
expect(validateEmail('user@example.com')).toBe(true)
expect(validateEmail('user+tag@example.co.uk')).toBe(true)
})
it('rejects invalid email addresses', () => {
expect(validateEmail('not-an-email')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
expect(validateEmail('user@')).toBe(false)
expect(validateEmail('')).toBe(false)
})
})
describe('validatePassword', () => {
it('accepts strong passwords', () => {
expect(validatePassword('Password1')).toEqual({ valid: true })
expect(validatePassword('MySecure99!')).toEqual({ valid: true })
})
it('rejects short passwords', () => {
const result = validatePassword('Abc1')
expect(result.valid).toBe(false)
expect(result.reason).toBe('Too short')
})
it('requires uppercase letter', () => {
const result = validatePassword('password1')
expect(result.valid).toBe(false)
expect(result.reason).toBe('Needs uppercase letter')
})
it('requires a number', () => {
const result = validatePassword('PasswordOnly')
expect(result.valid).toBe(false)
expect(result.reason).toBe('Needs a number')
})
})
Testing Async Code
Most Node.js code is async. Here's how to test it properly.
Promises
import { describe, it, expect } from 'vitest'
import { fetchUserById } from './userService.js'
describe('fetchUserById', () => {
it('returns user for valid ID', async () => {
const user = await fetchUserById(1)
expect(user).toMatchObject({ id: 1, name: expect.any(String) })
})
it('throws for unknown ID', async () => {
await expect(fetchUserById(999)).rejects.toThrow('User not found')
})
})
Callbacks (Legacy APIs)
import { promisify } from 'node:util'
import { readFile } from 'node:fs'
const readFileAsync = promisify(readFile)
it('reads config file', async () => {
const content = await readFileAsync('./config.json', 'utf8')
const config = JSON.parse(content)
expect(config).toHaveProperty('apiUrl')
})
Event Emitters
import { EventEmitter } from 'node:events'
it('emits data event', done => {
const emitter = new DataProcessor()
emitter.on('data', result => {
expect(result).toBe('processed')
done() // Signal test completion
})
emitter.on('error', done) // Fail on error
emitter.process('raw input')
})
// Or with async/await:
it('emits data event', async () => {
const emitter = new DataProcessor()
const result = await new Promise((resolve, reject) => {
emitter.on('data', resolve)
emitter.on('error', reject)
emitter.process('raw input')
})
expect(result).toBe('processed')
})
Mocking
Mocking Modules
// src/userService.js
import { db } from './database.js'
export async function getUserCount() {
const result = await db.query('SELECT COUNT(*) FROM users')
return result.rows[0].count
}
// src/userService.test.js
import { describe, it, expect, vi } from 'vitest'
import { getUserCount } from './userService.js'
import { db } from './database.js'
// Mock the database module
vi.mock('./database.js', () => ({
db: {
query: vi.fn()
}
}))
describe('getUserCount', () => {
it('returns the user count from the database', async () => {
db.query.mockResolvedValue({ rows: [{ count: 42 }] })
const count = await getUserCount()
expect(count).toBe(42)
expect(db.query).toHaveBeenCalledWith('SELECT COUNT(*) FROM users')
})
})
Mocking HTTP Requests
Use msw (Mock Service Worker) for HTTP mocking — it works in both Node.js and browsers:
npm install --save-dev msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { fetchGitHubUser } from './github.js'
const server = setupServer(
http.get('https://api.github.com/users/:username', ({ params }) => {
return HttpResponse.json({
login: params.username,
id: 12345,
name: 'Test User'
})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('fetches GitHub user data', async () => {
const user = await fetchGitHubUser('testuser')
expect(user.login).toBe('testuser')
expect(user.name).toBe('Test User')
})
it('handles 404', async () => {
server.use(
http.get('https://api.github.com/users/:username', () => {
return HttpResponse.json({ message: 'Not Found' }, { status: 404 })
})
)
await expect(fetchGitHubUser('nobody')).rejects.toThrow('User not found')
})
Spying on Functions
import { vi, expect } from 'vitest'
const logger = {
info: vi.fn(),
error: vi.fn()
}
await processPayment(order, { logger })
expect(logger.info).toHaveBeenCalledWith(
'Payment processed',
expect.objectContaining({ orderId: order.id })
)
expect(logger.error).not.toHaveBeenCalled()
Integration Tests
Integration tests verify that components work together correctly — usually testing a service with a real database or real HTTP calls.
Testing Express Routes
// app.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import request from 'supertest'
import app from './app.js'
import { db } from './database.js'
beforeAll(async () => {
await db.migrate.latest() // Run migrations on test DB
})
afterAll(async () => {
await db.destroy()
})
describe('POST /api/users', () => {
it('creates a user with valid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201)
expect(response.body).toMatchObject({
id: expect.any(Number),
name: 'Alice',
email: 'alice@example.com'
})
})
it('returns 400 for missing email', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Alice' })
.expect(400)
expect(response.body.error).toContain('email')
})
it('returns 409 for duplicate email', async () => {
await request(app).post('/api/users').send({ email: 'bob@example.com' })
await request(app).post('/api/users').send({ email: 'bob@example.com' }).expect(409)
})
})
Test Database Setup
Use a separate test database. Set it in your test config:
// vitest.config.js
export default {
test: {
env: {
DATABASE_URL: 'postgresql://localhost/myapp_test'
},
setupFiles: ['./test/setup.js']
}
}
// test/setup.js
import { beforeEach } from 'vitest'
import { db } from '../src/database.js'
beforeEach(async () => {
// Clear all tables before each test
await db.raw('TRUNCATE users, orders, products RESTART IDENTITY CASCADE')
})
Testing CLI Applications
// src/cli.js
import { program } from 'commander'
program
.command('greet <name>')
.option('--shout', 'uppercase the output')
.action((name, options) => {
const greeting = `Hello, ${name}!`
console.log(options.shout ? greeting.toUpperCase() : greeting)
})
program.parse(process.argv)
// src/cli.test.js
import { execSync } from 'node:child_process'
import { describe, it, expect } from 'vitest'
function cli(args) {
return execSync(`node src/cli.js ${args}`, { encoding: 'utf8' }).trim()
}
describe('greet command', () => {
it('greets by name', () => {
expect(cli('greet Alice')).toBe('Hello, Alice!')
})
it('shouts when --shout flag is set', () => {
expect(cli('greet Alice --shout')).toBe('HELLO, ALICE!')
})
})
Code Coverage
# Vitest
npx vitest run --coverage
<span class="hljs-comment"># Jest
npx jest --coverage
Configure coverage thresholds:
// vitest.config.js
export default {
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
},
exclude: ['node_modules', 'dist', '**/*.test.js']
}
}
}
CI/CD Setup
GitHub Actions
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: myapp_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run tests
run: npm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost/myapp_test
NODE_ENV: test
Common Patterns
Factory Functions for Test Data
// test/factories.js
let counter = 0
export function createUser(overrides = {}) {
counter++
return {
id: counter,
name: `User ${counter}`,
email: `user${counter}@example.com`,
role: 'user',
createdAt: new Date().toISOString(),
...overrides
}
}
const adminUser = createUser({ role: 'admin' })
const inactiveUser = createUser({ active: false, email: 'inactive@example.com' })
Testing Error Handling
it('handles database connection failure gracefully', async () => {
db.query.mockRejectedValue(new Error('Connection refused'))
const response = await request(app).get('/api/users').expect(503)
expect(response.body.error).toBe('Service temporarily unavailable')
})
Snapshot Testing
it('generates correct invoice object', () => {
const invoice = generateInvoice({
items: [{ name: 'Widget', price: 9.99, qty: 2 }],
taxRate: 0.08
})
expect(invoice).toMatchSnapshot()
// First run: creates a snapshot file
// Subsequent runs: compares against saved snapshot
})
Getting Started Checklist
- Pick a tool:
node:test(zero deps) or Vitest (recommended) - Write tests alongside source files (
foo.js→foo.test.js) - Run tests in watch mode during development:
vitest - Set up test database separate from dev database
- Add GitHub Actions workflow to run tests on every push
- Enforce coverage thresholds to prevent regressions
Building a Node.js API and need to verify it works end-to-end? HelpMeTest can generate and run API tests against your running server — no boilerplate required.