Skip to content

Unit Testing in Node.js

Unit tests verify small, isolated pieces of logic in complete isolation from databases, networks, and other slow dependencies. This lesson covers writing focused, fast unit tests in Node.js.

What Makes a Good Unit Test?

A unit test exercises one function or module, in isolation, with all its external dependencies replaced by mocks or stubs. This isolation is what makes unit tests fast (no real network or database calls) and reliable (they don't fail due to an external service being temporarily down).

Good unit tests are also focused: each test verifies one specific behavior, with a clear, descriptive name, making failures easy to diagnose without digging through unrelated logic.

import { describe, test } from 'node:test';
import assert from 'node:assert';

function calculateDiscount(price, percentage) {
  if (percentage < 0 || percentage > 100) {
    throw new RangeError('Percentage must be between 0 and 100');
  }
  return price - (price * percentage) / 100;
}

describe('calculateDiscount', () => {
  test('applies a percentage discount correctly', () => {
    assert.strictEqual(calculateDiscount(100, 20), 80);
  });

  test('throws for an invalid percentage', () => {
    assert.throws(() => calculateDiscount(100, 150), RangeError);
  });
});

Two focused tests, one for the happy path, one for an invalid input, cover the function's core behavior clearly and independently.

Structuring Tests With describe/test

describe('module or function name', () => {
  test('specific behavior being tested', () => {
    // arrange, act, assert
  });
});
  • describe() groups related tests under a shared label.
  • Each test() (or it()) should verify exactly one specific behavior.
  • Follow an "arrange, act, assert" structure: set up input, call the function, check the result.
  • Test names should read like a sentence describing the expected behavior.

Unit Testing Cheatsheet

Common assertions and patterns using node:assert.

Assertion Purpose
assert.strictEqual(a, b) Checks exact equality
assert.deepStrictEqual(a, b) Checks deep equality of objects/arrays
assert.throws(fn, ErrorType) Checks that a function throws
assert.ok(value) Checks a value is truthy
assert.rejects(promise) Checks that a promise rejects

Mocking Dependencies

When a function depends on something slow or external, a database call, a third-party API, a unit test replaces that dependency with a mock, a fake implementation you control completely, so the test stays fast and deterministic.

import { test, mock } from 'node:test';
import assert from 'node:assert';

function greetUser(userId, fetchUser) {
  const user = fetchUser(userId);
  return `Hello, ${user.name}!`;
}

test('greets the user by name', () => {
  const fetchUser = mock.fn(() => ({ name: 'Ada' }));
  const result = greetUser(1, fetchUser);

  assert.strictEqual(result, 'Hello, Ada!');
  assert.strictEqual(fetchUser.mock.callCount(), 1);
});

mock.fn() creates a fake function you can assert was called correctly, without needing a real database or API behind it.

Don't Forget Edge Cases

The most valuable unit tests often cover edge cases, empty arrays, zero values, negative numbers, unexpected types, not just the most obvious happy-path scenario. Bugs tend to hide at boundaries.

Common Mistakes

  • Letting unit tests make real network or database calls, making them slow and flaky.
  • Testing multiple unrelated behaviors in a single test, making failures hard to diagnose.
  • Only testing the happy path and skipping edge cases and error conditions.
  • Giving tests vague names like 'test 1' instead of describing the actual behavior being verified.

Key Takeaways

  • Unit tests verify one function or module in isolation from slow external dependencies.
  • Mocks replace real dependencies (databases, APIs) with fast, controllable fakes.
  • Each test should verify one specific, clearly named behavior.
  • Edge cases and error conditions deserve tests just as much as the happy path.

Pro Tip

Write the test name as a sentence describing expected behavior ('throws for a negative discount percentage') rather than a vague label. When a test fails months later, a clear name alone often tells you exactly what broke, before you even read the assertion.