Back to Testing guides

A Beginner's Guide to Unit Testing with Mocha

Stanley Ulili
Updated on March 21, 2025

Mocha is one of the most established testing frameworks in the JavaScript ecosystem.

Mocha takes a minimalist approach—offering a powerful test runner while allowing you to choose your assertion libraries, mocking tools, and test structure.

This flexibility has made Mocha a popular choice for testing in Node.js, where customization and modular tooling are often essential.

In this guide, you’ll learn how to use Mocha alongside Chai to write clear assertions and Sinon to create mocks and spies—equipping you with everything you need to build a reliable and maintainable testing workflow.

Prerequisites

You'll need Node.js installed on your system to work through this tutorial. While Mocha works with multiple Node.js versions, this guide uses features available in Node.js 22.x and above.

Step 1 — Setting up your testing environment

Mocha is designed for flexibility, with minimal setup required. In this section, you’ll set up a basic testing environment and prepare your project to use Mocha, along with other helpful testing tools.

Start by creating a new project directory:

 
mkdir mocha-testing && cd mocha-testing

Then, initialize a new Node.js project:

 
npm init -y

This creates a package.json file to manage your project’s dependencies and settings.

Next, enable ES Modules in your project by adding a "type" field to the package.json:

 
npm pkg set type="module"

Now, install Mocha itself. While many frameworks handle everything from test discovery to assertions, Mocha primarily focuses on being an excellent test runner:

 
npm install --save-dev mocha

One of Mocha’s key design choices is to leave out an assertion library, allowing you to choose the style that fits your project best.

In this guide, you’ll use Chai, a widely used and versatile library that supports three assertion styles: should, expect, and assert.

 
npm install --save-dev chai

Set up your test script in package.json to run Mocha by adding the following under the "scripts" section:

 
npm pkg set scripts.test="mocha"

Mocha follows convention over configuration principles, automatically looking for test files in a test directory, with no need for complicated configuration files:

 
mkdir test

Next, create a sample module in the root directory to test. Because this project uses ES Modules, make sure to use export and import syntax:

utils.js
export function multiply(a, b) {
  return a * b;
}

This simplicity has helped Mocha remain one of the most widely used testing tools in the JavaScript ecosystem, consistently adapting to evolving development practices.

Step 2 — Writing your first test

In this step, you’ll write your first test using Mocha’s BDD-style syntax. The describe and it functions help organize tests in a readable, structured way.

Create a test file that uses Mocha’s nested structure and ES Module imports:

test/utils.test.js
import { expect } from 'chai';
import { multiply } from '../utils.js';

describe('Utility Functions', function() {
  describe('multiply function', function() {
    it('should return 6 when multiplying 2 and 3', function() {
      expect(multiply(2, 3)).to.equal(6);
    });
  });
});

This test shows how Mocha organizes tests using a clear, readable structure:

  • The outer describe defines a test suite that groups related tests together.
  • The inner describe provides context for a specific function or feature.
  • The it block defines an individual test case with a clear expected outcome.
  • Chai’s expect style makes assertions easy to read and understand.

Mocha’s nested structure was a major shift when it was introduced. Unlike traditional xUnit-style frameworks, Mocha’s BDD-style syntax encourages writing tests in a way that reads like natural language. The it function combines with the test description to form a complete sentence—like:
"it should return 6 when multiplying 2 and 3."

This approach helps bridge the gap between technical implementation and business logic, making test output more accessible.

Step 3 — Running your tests

In this section, you’ll run the test you wrote using Mocha’s built-in test runner. Mocha provides clear and structured output to help you quickly identify which tests pass or fail.

Run the test suite with the following command:

 
npm test

Mocha's default "spec" reporter presents results in a hierarchical tree that mirrors your test structure:

Output
> mocha-testing@1.0.0 test
> mocha



  Utility Functions
    multiply function
      ✔ should return 6 when multiplying 2 and 3


  1 passing (2ms)

The indentation and checkmarks provide immediate visual feedback about which tests passed. This human-friendly output is one of Mocha's most distinctive features, designed to make failures immediately apparent.

Now, expand your test coverage by adding more scenarios to ensure the multiply function behaves correctly under different conditions:

test/utils.test.js
import { expect } from 'chai';
import { multiply } from '../utils.js';

describe('Utility Functions', function() {
  describe('multiply function', function() {
    it('should return 6 when multiplying 2 and 3', function() {
      expect(multiply(2, 3)).to.equal(6);
    });

it('should return 0 when multiplying by 0', function() {
expect(multiply(5, 0)).to.equal(0);
expect(multiply(0, 5)).to.equal(0);
});
it('should handle negative numbers correctly', function() {
expect(multiply(-2, 3)).to.equal(-6);
expect(multiply(2, -3)).to.equal(-6);
expect(multiply(-2, -3)).to.equal(6);
});
}); });

In this update, the highlighted test cases cover edge cases that are common sources of bugs:

  • Multiplying by zero, which should always return 0
  • Using negative numbers, which should follow standard multiplication rules for signs

These additional tests help ensure your function is reliable in various situations. As your test suite grows, covering typical and edge cases will help prevent regressions and catch issues early.

When you rerun the tests, Mocha's color-coded output makes scanning results effortless:

Screenshot of Mocha's color-coded output

If a test fails, Mocha highlights the failure in red and shows exactly what went wrong, including the expected and actual values. This level of detail makes it easy to spot and fix issues quickly—no need to insert extra console.log statements just to debug your tests.

Step 4 — Testing asynchronous code

Modern JavaScript applications frequently rely on asynchronous operations. Whether you're calling external APIs, reading files, or querying databases, handling async code in tests is essential. Mocha excels in this area, with built-in support for various async patterns.

Create an asynchronous function in the root directory to simulate an API call:

api.js
export function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    // Simulate API call delay
    setTimeout(() => {
      if (!userId) {
        reject(new Error('User ID is required'));
        return;
      }

      resolve({
        id: userId,
        name: 'User ' + userId,
        role: 'Member'
      });
    }, 100);
  });
}

This function returns user data after a simulated delay or throws an error if no user ID is provided. Now, test it using Mocha’s built-in support for async/await:

test/api.test.js
import { expect } from 'chai';
import { fetchUserData } from '../api.js';

describe('API Functions', function() {
  // Increase timeout for all tests in this suite
  this.timeout(500);

  describe('fetchUserData function', function() {
    it('should return user data for valid user ID', async function() {
      const data = await fetchUserData(123);
      expect(data).to.have.property('id', 123);
      expect(data).to.have.property('name', 'User 123');
      expect(data).to.have.property('role', 'Member');
    });

    it('should throw an error for missing user ID', async function() {
      try {
        await fetchUserData();
        // If we reach here, the test should fail
        expect.fail('Expected an error to be thrown');
      } catch (error) {
        expect(error.message).to.equal('User ID is required');
      }
    });
  });
});

This test file shows how naturally Mocha supports asynchronous code using modern JavaScript:

  • Declaring the test function as async lets Mocha wait for the Promise to resolve or reject.
  • await makes async code easier to read and reason about, especially in tests.
  • For success cases, results are awaited and directly asserted.
  • A try/catch block ensures the expected error is properly handled and verified for failure cases.

The this.timeout(500) line increases the timeout limit for the entire test suite. Mocha’s default timeout is 2000ms, but adjusting the timeout ensures the test doesn't fail prematurely in situations involving delayed responses—like simulated API calls.

When you run these tests with npm test, you'll see results like:

Output
  API Functions
    fetchUserData function
      ✔ should return user data for valid user ID (103ms)
      ✔ should throw an error for missing user ID (101ms)

  Utility Functions
    multiply function
      ✔ should return 6 when multiplying 2 and 3
      ...

  5 passing (209ms)

The test output includes the execution time for each test, reflecting the simulated delay in your asynchronous function. Mocha automatically waits for any returned promises to resolve before moving on to the next test, ensuring that asynchronous logic is handled correctly without extra setup.

Step 5 — Using hooks for setup and teardown

When working with multiple tests, it's common to set up shared state before tests run and clean it up afterward. Mocha provides lifecycle hooks to handle these scenarios, helping you keep your test code organized and avoid repetition.

The following example shows how to use Mocha’s hooks by testing a simple counter class. Start by creating a file named counter.js in the root directory:

counter.js
export default class Counter {
  constructor(initialValue = 0) {
    this.count = initialValue;
  }

  increment() { return ++this.count; }
  decrement() { return --this.count; }
  getValue() { return this.count; }
}

Now, write tests using Mocha's hooks:

test/counter.test.js
import { expect } from 'chai';
import Counter from '../counter.js';

describe('Counter Class', function() {
  let counter;

  beforeEach(function() {
    counter = new Counter();  // Fresh instance for each test
  });

  it('should start at 0', function() {
    expect(counter.getValue()).to.equal(0);
  });

  it('should increment correctly', function() {
    counter.increment();
    expect(counter.getValue()).to.equal(1);
  });
});

This test file demonstrates the use of the beforeEach hook, which runs before every individual test. In this case, it initializes a new Counter instance before each test run. This ensures that tests are isolated and do not interfere with one another—an essential practice for writing reliable and maintainable test suites.

Run these tests with npm test, and you'll see:

Output
  API Functions
    ...

  Counter Class
    ✔ should start at 0
    ✔ should increment correctly

   ...

  7 passing (210ms)

Mocha provides four main hooks that run in a specific sequence:

 
before(() => {/* runs once before all tests */});
beforeEach(() => {/* runs before each test */});
afterEach(() => {/* runs after each test */});
after(() => {/* runs once after all tests */});
  • before: Runs once before any tests in the block begin. Ideal for one-time setup like database connections or loading test fixtures.
  • beforeEach: Runs before each individual test. Perfect for creating fresh instances or resetting state, as shown in our example.
  • afterEach: Runs after each individual test. Used for cleanup tasks like resetting mocks or clearing temporary data.
  • after: Runs once after all tests complete. Suitable for closing connections or final cleanup.

These hooks are especially valuable when dealing with shared resources that require controlled setup and teardown—such as database connections, file system access, environment variable changes, or complex object initialization.

They’re also helpful for managing test doubles (like stubs and mocks), which you’ll explore in the next section. Using hooks properly ensures each test runs in a clean, isolated environment, leading to more consistent and trustworthy test results.

Step 6 — Mocking with Sinon.js

It's important to isolate those tests from the actual dependencies when testing code that relies on external systems—such as APIs, databases, or the file system.

This is where mocking becomes essential. Sinon.js is a powerful library that integrates smoothly with Mocha and provides test doubles like spies, stubs, and mocks.

Start by installing Sinon as a development dependency:

 
npm install --save-dev sinon

Next, create a service that depends on an external API client:

userService.js
export default class UserService {
  constructor(apiClient) {
    this.apiClient = apiClient;
  }

  async getUserName(userId) {
    const userData = await this.apiClient.fetchUserData(userId);
    return userData.name;
  }
}

This service method depends on an apiClient to retrieve user information. The client might perform HTTP requests or database queries in a real-world scenario. However, in testing, you want to avoid relying on external systems.

Now, write a test using Sinon to create a test double for the apiClient:

test/userService.test.js
import { expect } from 'chai';
import sinon from 'sinon';
import UserService from '../userService.js';

describe('UserService', function() {
  it('should return the user name from API data', async function() {
    // Create a stub for the apiClient
    const apiClientStub = {
      fetchUserData: sinon.stub().resolves({
        id: 123,
        name: 'John Doe',
        role: 'Member'
      })
    };

    // Create a UserService with the stubbed apiClient
    const userService = new UserService(apiClientStub);

    // Call the method and verify the result
    const name = await userService.getUserName(123);
    expect(name).to.equal('John Doe');

    // Verify the stub was called correctly
    expect(apiClientStub.fetchUserData.calledOnce).to.be.true;
  });
});

In this test, the apiClient dependency is replaced with a stub that returns mock data. This allows the test to focus on the logic inside UserService without relying on actual external calls.

This approach ensures your test is:

  • Isolated from network or database dependencies
  • Predictable, with controlled input and output
  • Fast, since there are no real external operations

Run this test with npm test, and you'll see:

Output
  UserService
    ✔ should return the user name from API data

  ...

  8 passing (212ms)

Sinon makes it easy to test components in isolation through its flexible approach to test doubles.

Stubs allow you to replace real functions with controlled, predictable alternatives. Spies give you insight into how functions are used, such as how many times they're called and with which arguments, without changing their actual behavior.

Mocks go a step further, combining behavior replacement with built-in expectations about how functions should be used.

Using these techniques, you can simulate both normal and error conditions, keep tests fast and independent, and gain confidence that each piece of your code behaves as expected—even when working with external dependencies.

Final thoughts

Mocha’s minimalist, flexible design makes it a solid choice for testing JavaScript applications—especially in Node.js environments where control and customization are essential.

If you're looking for a more modern testing experience—especially in projects using Vite or working in the browser—consider Vitest. It offers a faster test runner, built-in mocking, snapshot support, and first-class TypeScript integration, all with a familiar syntax inspired by Mocha and Jest.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Load Testing Node.js with Artillery: A Beginner's Guide
Observability and monitoring reveals problems, but load testing with Artillery can help you find them before they happen.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github