How to write unit tests in Node.js using Mocha

Reliable software is built by checking small behaviours before they become larger problems. In a Node.js application, unit tests isolate individual functions, supply controlled inputs and verify predictable outputs. Mocha is a popular JavaScript test framework for organising those checks, while an assertion library such as Node’s built-in assert module evaluates the results.

This approach suits projects of every size, from a small landing page to a production API serving customers across Sydney, Melbourne and regional New South Wales. A clear test suite gives developers faster feedback, makes refactoring safer and documents how each part of the application should behave.

Why unit testing matters in Node.js

A unit test focuses on one unit of logic, usually a function or module, without depending on a database, external API or browser. For example, a price calculator can be tested with several input values without launching the entire application. When a test fails, the limited scope makes the cause easier to locate.

Node.js applications often contain asynchronous code, file operations and integrations that can create unpredictable results. Unit testing separates those concerns. A function that validates an Australian postcode, converts a date to AEST or calculates a delivery fee can be checked consistently, regardless of network conditions or whether the test is run in Perth or Brisbane.

Tests also protect a codebase during change. A developer can update a function, run the test command and quickly discover whether an existing contract has been broken. This is particularly useful for Australian teams working across AEST and AEDT, where developers may hand work between offices in different states.

Setting up Mocha in a project

Create a Node.js project if one does not already exist:

mkdir mocha-demo
cd mocha-demo
npm init -y
npm install --save-dev mocha

Mocha can be run with the npx mocha command. Add a script to package.json so the project has a repeatable test entry point:

{
  "scripts": {
    "test": "mocha"
  }
}

Mocha searches for test files in a test directory by default. Create a simple module in src/math.js:

function add(a, b) {
  return a + b;
}

module.exports = { add };

Then create test/math.test.js:

const assert = require('node:assert/strict');
const { add } = require('../src/math');

describe('add', function () {
  it('returns the sum of two numbers', function () {
    assert.equal(add(2, 3), 5);
  });
});

Run the suite with npm test. A green result means the assertion passed, while a failure includes the expected and actual values. The same process works whether the project supports a local business in Adelaide or a national online service.

Writing clear test cases

Mocha provides describe blocks for grouping related behaviours and it blocks for individual expectations. Test names should describe observable behaviour rather than implementation details. “Returns the sum of two numbers” remains useful if the internal calculation changes, while “calls the plus operator” is too closely tied to the current code.

Good unit tests cover normal inputs, boundary values and invalid data. Consider a function that applies a discount:

function finalPrice(price, discount) {
  if (price < 0 || discount < 0 || discount > 100) {
    throw new RangeError('Invalid pricing value');
  }

  return price * (1 - discount / 100);
}

The test suite should check a full-price purchase, a discounted purchase and rejected values:

assert.equal(finalPrice(100, 20), 80);
assert.equal(finalPrice(100, 0), 100);
assert.throws(() => finalPrice(100, 120), RangeError);

This style keeps each test focused. A small set of precise cases is generally more valuable than a long test that performs many unrelated actions. Tests should also use realistic values, such as prices in Australian dollars, without embedding unnecessary assumptions about a particular suburb or retailer.

Handling asynchronous functions

Many Node.js functions return promises. Mocha supports asynchronous tests when the test function is declared with async, so failures from rejected promises are reported correctly:

async function loadUserName(getUser) {
  const user = await getUser();
  return user.name;
}

describe('loadUserName', function () {
  it('returns the name from the user response', async function () {
    const fakeUser = async () => ({ name: 'Taylor' });

    const result = await loadUserName(fakeUser);

    assert.equal(result, 'Taylor');
  });
});

Do not combine an async function with the old done callback unless there is a specific reason. Returning or awaiting the promise gives Mocha a clear signal that the test has finished. A useful reference for promise behaviour is these async/await examples, especially when a test involves several sequential operations.

Timeouts should be treated as possible defects rather than routine inconveniences. A request that hangs because a promise was not returned can make a test appear stuck. Keep external services outside unit tests where possible, replacing them with controlled fakes or stubs.

Isolating dependencies with stubs

A unit test should control its dependencies. If a function requests data from a payment provider, weather service or database, calling the real service makes the test slow and fragile. A stub replaces that dependency with a predictable function.

For example:

async function getGreeting(userClient, id) {
  const user = await userClient.findById(id);
  return `Hello, ${user.name}`;
}

it('greets the returned user', async function () {
  const userClient = {
    findById: async () => ({ name: 'Sam' })
  };

  assert.equal(await getGreeting(userClient, 7), 'Hello, Sam');
});

This pattern uses dependency injection rather than importing a hard-coded client inside the function. It is simple, transparent and easy to maintain. For larger suites, Sinon can provide spies, stubs and mocks, but Mocha itself does not require an additional mocking library.

The same principle applies to game or pricing logic. If a module calculates the outcome of a card hand, test it with fixed cards rather than relying on random generation. A short explanation of card game rules can provide domain context, while the actual unit test should still supply deterministic inputs and verify one defined outcome.

Organising a maintainable test suite

A practical project commonly separates application code from tests:

project/
  src/
    calculator.js
    user-service.js
  test/
    calculator.test.js
    user-service.test.js
  package.json

Match test files to the modules they cover, and keep shared setup small. Mocha’s hooks provide before, after, beforeEach and afterEach for preparation and cleanup:

describe('cart', function () {
  let cart;

  beforeEach(function () {
    cart = [];
  });

  it('starts empty', function () {
    assert.deepEqual(cart, []);
  });
});

Avoid placing too much logic in hooks because it can hide what each test requires. A reader should be able to understand the main arrangement, action and assertion without searching through several setup files. Clear structure is valuable for teams maintaining software during an Australian summer shutdown or a busy end-of-financial-year release.

A lightweight landing page such as simple page design may have little backend logic, yet its supporting JavaScript can still be tested. Menu toggles, form validation and content-formatting functions are all suitable candidates for small, isolated checks.

Recommendations for reliable tests

A useful test suite balances coverage with speed and clarity. Code coverage reports can reveal untested paths, but a high percentage does not guarantee meaningful assertions. Focus on important business rules, error handling and boundaries first.

Use these practices when building a Mocha suite:

Run tests automatically in a continuous integration pipeline so every branch receives the same check. A typical command is npm test, and the pipeline should fail when Mocha reports an assertion error or an unhandled rejection. Teams serving customers across Australia can then detect regressions before a release reaches users on a busy Sydney connection, a regional NBN service or a mobile device in transit.

Mocha is deliberately flexible: it provides test discovery, grouping, hooks and execution while allowing the project to choose its assertion and mocking tools. With small modules, controlled dependencies and explicit asynchronous handling, Node.js unit tests become a dependable part of everyday development rather than a task reserved for the end of a project.

Contact