Node.js Testing: Tools, Patterns, and Best Practices

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')
})
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.jsfoo.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.

Read more