DEV Community

Rahul Singh
Rahul Singh

Posted on • Originally published at aicodereview.cc

Unit Testing with Mocha and Chai: JS Guide

What is unit testing and why does it matter?

A unit test verifies that a single, isolated piece of your code -- a function, a method, a class -- does what it is supposed to do. You give it known inputs and check that the outputs match your expectations. If someone changes that code later and breaks its behavior, the test catches it before the bug reaches production.

The value of unit testing is not theoretical. Teams with strong test suites ship faster because they refactor with confidence, catch regressions early, and spend less time debugging in production. Teams without tests move cautiously, fear changes to core logic, and rely on manual QA that misses edge cases.

Unit testing is the foundation of the testing pyramid. Integration tests verify that components work together. End-to-end tests verify entire user workflows. But unit tests are the fastest, cheapest, and most numerous tests in a healthy codebase. They run in milliseconds, pinpoint exactly which function broke, and give you immediate feedback during development.

Where Mocha and Chai fit in the JavaScript testing ecosystem

Mocha is a test framework (often called a test runner). It provides the structure for organizing tests into suites (describe blocks) and individual cases (it blocks). It manages test lifecycle hooks, handles async code, generates reports, and integrates with CI/CD pipelines. Mocha does not include an assertion library or mocking tools -- it lets you choose those yourself.

Chai is an assertion library. It gives you expressive ways to check whether your code produced the right result. Instead of writing if (result !== 5) throw new Error(...), you write expect(result).to.equal(5). Chai supports three assertion styles (expect, should, and assert) and a rich vocabulary of chainable matchers.

Together, Mocha + Chai form a modular testing stack. You pick each piece. You can swap Chai for Node's built-in assert. You can add Sinon for mocking, Supertest for HTTP assertions, and NYC for code coverage. This composability is Mocha's core design philosophy and its biggest differentiator from all-in-one frameworks.

Mocha vs Jest vs Vitest: how do they compare?

Before committing to a testing stack, it helps to understand the landscape. Here is how Mocha compares to the two other major JavaScript test frameworks.

Feature Mocha + Chai Jest Vitest
Philosophy Modular, bring your own tools All-in-one, batteries included All-in-one, Vite-native
Assertion library Your choice (Chai, assert, etc.) Built-in expect Built-in expect (Jest-compatible)
Mocking Your choice (Sinon, testdouble, etc.) Built-in jest.fn(), jest.mock() Built-in vi.fn(), vi.mock()
Code coverage NYC/Istanbul (separate install) Built-in (--coverage) Built-in (v8 or Istanbul)
Async support Promises, async/await, done callback Promises, async/await Promises, async/await
Configuration .mocharc.yml or CLI flags jest.config.js vitest.config.ts
ESM support Yes (with --loader flag) Experimental Native
Speed Fast Moderate (worker isolation) Very fast (Vite transforms)
Watch mode --watch --watch (default) --watch (default)
Best for Node.js backends, teams wanting full control React apps, monorepos, all-in-one setups Vite-based projects, TypeScript-first teams
Weekly npm downloads ~6M (Mocha) ~30M ~7M

When to choose Mocha: You want full control over your tooling, you are building a Node.js backend, your team already knows Mocha, or you need to use a specific assertion or mocking library. Mocha's flexibility means it never forces you into a pattern that does not fit your project.

When to choose Jest: You are building a React application, you want zero-config setup, or you prefer having assertions, mocks, and coverage in a single package.

When to choose Vitest: You are using Vite as your build tool, you want Jest-compatible APIs with faster execution, or you are starting a new TypeScript project.

This guide focuses entirely on Mocha + Chai because it remains the most flexible and educational testing stack. Understanding Mocha teaches you the fundamentals of test structure, hooks, and async patterns in a way that transfers to any framework.

Setting up your environment

Initialize the project

Start with a new Node.js project. If you already have one, skip to the install step.

// Terminal commands:
// mkdir mocha-testing-demo && cd mocha-testing-demo
// npm init -y
Enter fullscreen mode Exit fullscreen mode

Install Mocha and Chai

Install both as dev dependencies. You never need Mocha or Chai in production.

// Terminal:
// npm install --save-dev mocha chai
Enter fullscreen mode Exit fullscreen mode

As of early 2026, you should be on Mocha 10.x+ and Chai 5.x+ (which is ESM-only). If you are using CommonJS and need Chai 4.x, install it explicitly with npm install --save-dev chai@4.

Project structure

A clean test project looks like this:

// Project structure:
// mocha-testing-demo/
// ├── src/
// │   ├── math.js
// │   ├── user.js
// │   └── api.js
// ├── test/
// │   ├── math.test.js
// │   ├── user.test.js
// │   └── api.test.js
// ├── package.json
// └── .mocharc.yml
Enter fullscreen mode Exit fullscreen mode

By convention, test files go in a test/ directory and mirror the source file names with a .test.js suffix. Mocha looks in the test/ directory by default.

Configure the test script in package.json

Add a test script so you can run tests with npm test:

// package.json
{
  "name": "mocha-testing-demo",
  "version": "1.0.0",
  "scripts": {
    "test": "mocha",
    "test:watch": "mocha --watch",
    "test:coverage": "nyc mocha"
  },
  "devDependencies": {
    "mocha": "^10.8.0",
    "chai": "^4.4.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Mocha configuration with .mocharc.yml

Instead of passing flags on the command line every time, create a .mocharc.yml file in the project root:

// .mocharc.yml
// spec: test/**/*.test.js
// timeout: 5000
// recursive: true
// reporter: spec
// bail: false
// exit: true
Enter fullscreen mode Exit fullscreen mode

Here is what each option does:

  • spec -- The glob pattern Mocha uses to find test files. test/**/*.test.js matches all .test.js files in the test/ directory and its subdirectories.
  • timeout -- Maximum milliseconds a test can take before Mocha marks it as failed. The default is 2000ms. Increase this for slow tests (database calls, network requests).
  • recursive -- Look for test files in subdirectories of the test folder.
  • reporter -- The output format. spec gives you a hierarchical view of passing and failing tests. Other options include dot, nyan, json, and min.
  • bail -- If true, Mocha stops after the first failure. Useful during development when you want to fix one thing at a time.
  • exit -- Forces Mocha to exit after tests complete. Without this, hanging async operations can keep the process alive.

ESM vs CommonJS setup

Mocha supports both module systems. For CommonJS (the default in Node.js), use require:

// CommonJS - test/math.test.js
const { expect } = require('chai');
const { add } = require('../src/math');
Enter fullscreen mode Exit fullscreen mode

For ESM, add "type": "module" to your package.json and use import:

// ESM - test/math.test.js

Enter fullscreen mode Exit fullscreen mode

If you are using Chai 5.x, you must use ESM because Chai 5 dropped CommonJS support. For CommonJS projects, stay on Chai 4.x.

Your first test

Write a simple function

Create a math utility module with a few functions:

// src/math.js
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

module.exports = { add, subtract, multiply, divide };
Enter fullscreen mode Exit fullscreen mode

Write the test file

Now create the test file:

// test/math.test.js
const { expect } = require('chai');
const { add, subtract, multiply, divide } = require('../src/math');

describe('Math Utilities', function () {

  describe('add()', function () {
    it('should return the sum of two positive numbers', function () {
      const result = add(2, 3);
      expect(result).to.equal(5);
    });

    it('should handle negative numbers', function () {
      expect(add(-1, -4)).to.equal(-5);
    });

    it('should handle zero', function () {
      expect(add(0, 5)).to.equal(5);
    });
  });

  describe('subtract()', function () {
    it('should return the difference of two numbers', function () {
      expect(subtract(10, 4)).to.equal(6);
    });

    it('should return negative when second number is larger', function () {
      expect(subtract(3, 7)).to.equal(-4);
    });
  });

  describe('multiply()', function () {
    it('should return the product of two numbers', function () {
      expect(multiply(3, 4)).to.equal(12);
    });

    it('should return zero when multiplied by zero', function () {
      expect(multiply(5, 0)).to.equal(0);
    });
  });

  describe('divide()', function () {
    it('should return the quotient of two numbers', function () {
      expect(divide(10, 2)).to.equal(5);
    });

    it('should handle decimal results', function () {
      expect(divide(7, 2)).to.equal(3.5);
    });

    it('should throw an error when dividing by zero', function () {
      expect(() => divide(10, 0)).to.throw('Division by zero');
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Understanding describe and it blocks

describe() creates a test suite -- a logical group of related tests. You can nest describe blocks to create a hierarchy. The string argument is a label that appears in the test output.

it() defines an individual test case. The string argument describes what the test verifies. Good it descriptions read like sentences: "it should return the sum of two positive numbers."

Run the tests

Execute the tests from the terminal:

// Terminal:
// npx mocha

// Output:
//   Math Utilities
//     add()
//       ✓ should return the sum of two positive numbers
//       ✓ should handle negative numbers
//       ✓ should handle zero
//     subtract()
//       ✓ should return the difference of two numbers
//       ✓ should return negative when second number is larger
//     multiply()
//       ✓ should return the product of two numbers
//       ✓ should return zero when multiplied by zero
//     divide()
//       ✓ should return the quotient of two numbers
//       ✓ should handle decimal results
//       ✓ should throw an error when dividing by zero
//
//   10 passing (12ms)
Enter fullscreen mode Exit fullscreen mode

The spec reporter shows your describe and it labels in a tree structure. Green checkmarks mean passing. Red crosses mean failing. The total count and execution time appear at the bottom.

If a test fails, Mocha shows you the expected value, the actual value, and a stack trace pointing to the exact line.

Chai assertion styles deep dive

Chai provides three distinct assertion styles. They all do the same thing -- check whether a value meets your expectations -- but they differ in syntax and readability.

The expect style (BDD)

The expect style is the most popular. It wraps a value in an expect() call and chains assertions using natural language methods.

const { expect } = require('chai');

// Equality
expect(4 + 4).to.equal(8);
expect('hello').to.equal('hello');

// Type checking
expect('test').to.be.a('string');
expect(42).to.be.a('number');
expect(true).to.be.a('boolean');
expect([1, 2, 3]).to.be.an('array');
expect({ name: 'Alice' }).to.be.an('object');

// Truthiness
expect(true).to.be.true;
expect(false).to.be.false;
expect(null).to.be.null;
expect(undefined).to.be.undefined;
expect('non-empty').to.be.ok; // truthy
expect(0).to.not.be.ok;       // falsy

// Numeric comparisons
expect(10).to.be.above(5);
expect(10).to.be.below(20);
expect(10).to.be.at.least(10);
expect(10).to.be.at.most(10);
expect(5.5).to.be.within(5, 6);
expect(0.1 + 0.2).to.be.closeTo(0.3, 0.001);

// String assertions
expect('hello world').to.include('world');
expect('hello world').to.match(/^hello/);
expect('hello').to.have.lengthOf(5);

// Array assertions
expect([1, 2, 3]).to.include(2);
expect([1, 2, 3]).to.have.lengthOf(3);
expect([1, 2, 3]).to.deep.equal([1, 2, 3]);
expect([{ id: 1 }, { id: 2 }]).to.deep.include({ id: 1 });
expect([3, 1, 2]).to.have.members([1, 2, 3]);

// Object assertions
expect({ name: 'Alice', age: 30 }).to.have.property('name');
expect({ name: 'Alice', age: 30 }).to.have.property('name', 'Alice');
expect({ name: 'Alice', age: 30 }).to.include({ name: 'Alice' });
expect({ a: 1, b: 2 }).to.have.all.keys('a', 'b');
expect({ a: 1, b: 2, c: 3 }).to.have.any.keys('a', 'z');
expect({ a: { b: { c: 1 } } }).to.have.nested.property('a.b.c', 1);

// Negation
expect(5).to.not.equal(10);
expect([1, 2, 3]).to.not.include(4);
expect('hello').to.not.be.empty;
Enter fullscreen mode Exit fullscreen mode

The should style (BDD)

The should style extends Object.prototype so that every value gets a .should property. This makes assertions read like English sentences.

const chai = require('chai');
chai.should();

// Usage
(4 + 4).should.equal(8);
'hello'.should.be.a('string');
[1, 2, 3].should.have.lengthOf(3);
true.should.be.true;

const user = { name: 'Alice', age: 30 };
user.should.have.property('name');
user.should.include({ age: 30 });

// Problem: cannot use should on null or undefined
// null.should.be.null;  // TypeError: Cannot read property 'should' of null
// Workaround:
chai.should();
chai.expect(null).to.be.null;
Enter fullscreen mode Exit fullscreen mode

The should style looks elegant but has a fundamental flaw: it modifies Object.prototype, which means it does not work on null or undefined values -- exactly the things you often need to test. This is why most teams prefer expect.

The assert style (TDD)

The assert style is closest to Node's built-in assert module. It uses function calls rather than chaining.

const { assert } = require('chai');

assert.equal(4 + 4, 8);
assert.strictEqual(4 + 4, 8);
assert.notEqual(4 + 4, 9);

assert.isTrue(true);
assert.isFalse(false);
assert.isNull(null);
assert.isUndefined(undefined);
assert.isOk('truthy');
assert.isNotOk(0);

assert.typeOf('hello', 'string');
assert.typeOf(42, 'number');
assert.instanceOf(new Date(), Date);

assert.isAbove(10, 5);
assert.isBelow(5, 10);

assert.include([1, 2, 3], 2);
assert.include('hello world', 'world');

assert.lengthOf([1, 2, 3], 3);
assert.lengthOf('hello', 5);

assert.property({ name: 'Alice' }, 'name');
assert.deepEqual({ a: 1 }, { a: 1 });

assert.throws(() => { throw new Error('fail'); }, 'fail');
assert.doesNotThrow(() => 42);
Enter fullscreen mode Exit fullscreen mode

When to use which style

Style Syntax Pros Cons Best for
expect expect(val).to.equal(5) Works on null/undefined, most common, chainable Slightly verbose Most projects (recommended default)
should val.should.equal(5) Most readable English-like syntax Breaks on null/undefined, modifies prototype Teams prioritizing readability over edge cases
assert assert.equal(val, 5) Familiar to TDD practitioners, closest to Node assert Less expressive, no chaining Teams migrating from Node assert or TDD purists

Recommendation: Use expect. It is the industry standard, works in every situation, and produces the most readable test failures.

Understanding Chai's chainable language

Chai includes several words that are purely for readability and have no functional effect. These chainable getters include: to, be, been, is, that, which, and, has, have, with, at, of, same, but, does, still, also.

This means these two assertions are identical:

expect(5).to.be.a('number');
expect(5).a('number');

expect([1, 2]).to.have.lengthOf(2);
expect([1, 2]).lengthOf(2);
Enter fullscreen mode Exit fullscreen mode

The extra words exist solely to make your assertions read like natural language. Use them when they improve clarity.

Testing real-world patterns

Testing pure functions

Pure functions (same input always produces the same output, no side effects) are the easiest to test. Cover the happy path, edge cases, and boundary conditions.

// src/stringUtils.js
function capitalize(str) {
  if (typeof str !== 'string') {
    throw new TypeError('Expected a string');
  }
  if (str.length === 0) return '';
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

function slugify(str) {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

module.exports = { capitalize, slugify };
Enter fullscreen mode Exit fullscreen mode
// test/stringUtils.test.js
const { expect } = require('chai');
const { capitalize, slugify } = require('../src/stringUtils');

describe('String Utilities', function () {

  describe('capitalize()', function () {
    it('should capitalize the first letter and lowercase the rest', function () {
      expect(capitalize('hello')).to.equal('Hello');
      expect(capitalize('HELLO')).to.equal('Hello');
      expect(capitalize('hELLO wORLD')).to.equal('Hello world');
    });

    it('should return empty string for empty input', function () {
      expect(capitalize('')).to.equal('');
    });

    it('should handle single character strings', function () {
      expect(capitalize('a')).to.equal('A');
      expect(capitalize('Z')).to.equal('Z');
    });

    it('should throw TypeError for non-string input', function () {
      expect(() => capitalize(123)).to.throw(TypeError, 'Expected a string');
      expect(() => capitalize(null)).to.throw(TypeError);
      expect(() => capitalize(undefined)).to.throw(TypeError);
    });
  });

  describe('slugify()', function () {
    it('should convert a title to a URL-friendly slug', function () {
      expect(slugify('Hello World')).to.equal('hello-world');
    });

    it('should remove special characters', function () {
      expect(slugify('Hello, World!')).to.equal('hello-world');
    });

    it('should handle multiple spaces', function () {
      expect(slugify('hello   world')).to.equal('hello-world');
    });

    it('should trim leading and trailing whitespace', function () {
      expect(slugify('  hello world  ')).to.equal('hello-world');
    });

    it('should handle underscores', function () {
      expect(slugify('hello_world')).to.equal('hello-world');
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Testing objects and arrays

When comparing objects and arrays, use .deep.equal() for structural equality. Plain .equal() checks reference equality, which means two identical-looking objects will fail the comparison.

// src/userUtils.js
function formatUser(firstName, lastName, email) {
  return {
    fullName: `${firstName} ${lastName}`,
    email: email.toLowerCase(),
    initials: `${firstName[0]}${lastName[0]}`.toUpperCase(),
  };
}

function filterActiveUsers(users) {
  return users.filter(user => user.active === true);
}

function sortByAge(users) {
  return [...users].sort((a, b) => a.age - b.age);
}

module.exports = { formatUser, filterActiveUsers, sortByAge };
Enter fullscreen mode Exit fullscreen mode
// test/userUtils.test.js
const { expect } = require('chai');
const { formatUser, filterActiveUsers, sortByAge } = require('../src/userUtils');

describe('User Utilities', function () {

  describe('formatUser()', function () {
    it('should return a formatted user object', function () {
      const result = formatUser('Alice', 'Smith', 'ALICE@EXAMPLE.COM');

      expect(result).to.deep.equal({
        fullName: 'Alice Smith',
        email: 'alice@example.com',
        initials: 'AS',
      });
    });

    it('should have exactly three properties', function () {
      const result = formatUser('Bob', 'Jones', 'bob@test.com');
      expect(Object.keys(result)).to.have.lengthOf(3);
      expect(result).to.have.all.keys('fullName', 'email', 'initials');
    });
  });

  describe('filterActiveUsers()', function () {
    const users = [
      { name: 'Alice', active: true },
      { name: 'Bob', active: false },
      { name: 'Charlie', active: true },
      { name: 'Dana', active: false },
    ];

    it('should return only active users', function () {
      const result = filterActiveUsers(users);
      expect(result).to.have.lengthOf(2);
      expect(result.every(u => u.active)).to.be.true;
    });

    it('should return an empty array when no users are active', function () {
      const inactive = [{ name: 'Eve', active: false }];
      expect(filterActiveUsers(inactive)).to.deep.equal([]);
    });

    it('should return all users when all are active', function () {
      const allActive = [
        { name: 'Frank', active: true },
        { name: 'Grace', active: true },
      ];
      expect(filterActiveUsers(allActive)).to.have.lengthOf(2);
    });
  });

  describe('sortByAge()', function () {
    it('should sort users by age in ascending order', function () {
      const users = [
        { name: 'Charlie', age: 35 },
        { name: 'Alice', age: 25 },
        { name: 'Bob', age: 30 },
      ];
      const result = sortByAge(users);
      expect(result[0].name).to.equal('Alice');
      expect(result[1].name).to.equal('Bob');
      expect(result[2].name).to.equal('Charlie');
    });

    it('should not mutate the original array', function () {
      const users = [
        { name: 'Charlie', age: 35 },
        { name: 'Alice', age: 25 },
      ];
      sortByAge(users);
      expect(users[0].name).to.equal('Charlie');
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Testing error throwing

When you expect a function to throw, wrap it in a function so Chai can catch the error:

// Correct: wrap in a function
expect(() => divide(10, 0)).to.throw(Error);
expect(() => divide(10, 0)).to.throw('Division by zero');
expect(() => divide(10, 0)).to.throw(Error, 'Division by zero');

// Also valid: match error message with regex
expect(() => divide(10, 0)).to.throw(/division/i);

// Incorrect: this calls the function immediately and throws before Chai can catch it
// expect(divide(10, 0)).to.throw(); // DO NOT do this
Enter fullscreen mode Exit fullscreen mode

Testing class methods

// src/ShoppingCart.js
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(name, price, quantity = 1) {
    if (price < 0) throw new Error('Price cannot be negative');
    if (quantity < 1) throw new Error('Quantity must be at least 1');

    const existing = this.items.find(item => item.name === name);
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.items.push({ name, price, quantity });
    }
  }

  removeItem(name) {
    const index = this.items.findIndex(item => item.name === name);
    if (index === -1) throw new Error(`Item "${name}" not found`);
    this.items.splice(index, 1);
  }

  getTotal() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  getItemCount() {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }

  clear() {
    this.items = [];
  }
}

module.exports = ShoppingCart;
Enter fullscreen mode Exit fullscreen mode
// test/ShoppingCart.test.js
const { expect } = require('chai');
const ShoppingCart = require('../src/ShoppingCart');

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

  beforeEach(function () {
    cart = new ShoppingCart();
  });

  describe('addItem()', function () {
    it('should add an item to the cart', function () {
      cart.addItem('Apple', 1.5);
      expect(cart.items).to.have.lengthOf(1);
      expect(cart.items[0]).to.deep.equal({ name: 'Apple', price: 1.5, quantity: 1 });
    });

    it('should increase quantity for duplicate items', function () {
      cart.addItem('Apple', 1.5, 2);
      cart.addItem('Apple', 1.5, 3);
      expect(cart.items).to.have.lengthOf(1);
      expect(cart.items[0].quantity).to.equal(5);
    });

    it('should throw for negative price', function () {
      expect(() => cart.addItem('Apple', -1)).to.throw('Price cannot be negative');
    });

    it('should throw for quantity less than 1', function () {
      expect(() => cart.addItem('Apple', 1.5, 0)).to.throw('Quantity must be at least 1');
    });
  });

  describe('removeItem()', function () {
    it('should remove an existing item', function () {
      cart.addItem('Apple', 1.5);
      cart.addItem('Banana', 0.75);
      cart.removeItem('Apple');
      expect(cart.items).to.have.lengthOf(1);
      expect(cart.items[0].name).to.equal('Banana');
    });

    it('should throw when removing a non-existent item', function () {
      expect(() => cart.removeItem('Ghost')).to.throw('Item "Ghost" not found');
    });
  });

  describe('getTotal()', function () {
    it('should return 0 for an empty cart', function () {
      expect(cart.getTotal()).to.equal(0);
    });

    it('should calculate total correctly', function () {
      cart.addItem('Apple', 1.5, 3);  // 4.50
      cart.addItem('Banana', 0.75, 4); // 3.00
      expect(cart.getTotal()).to.equal(7.5);
    });
  });

  describe('getItemCount()', function () {
    it('should return the total number of items', function () {
      cart.addItem('Apple', 1.5, 3);
      cart.addItem('Banana', 0.75, 2);
      expect(cart.getItemCount()).to.equal(5);
    });
  });

  describe('clear()', function () {
    it('should remove all items from the cart', function () {
      cart.addItem('Apple', 1.5);
      cart.addItem('Banana', 0.75);
      cart.clear();
      expect(cart.items).to.be.empty;
      expect(cart.getTotal()).to.equal(0);
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Async testing

Real-world JavaScript is heavily asynchronous. Mocha provides three patterns for testing async code.

Testing Promises by returning them

If your test returns a Promise, Mocha waits for it to resolve (pass) or reject (fail).

// src/asyncUtils.js
function fetchUserById(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const users = {
        1: { id: 1, name: 'Alice' },
        2: { id: 2, name: 'Bob' },
      };
      const user = users[id];
      if (user) {
        resolve(user);
      } else {
        reject(new Error(`User ${id} not found`));
      }
    }, 100);
  });
}

module.exports = { fetchUserById };
Enter fullscreen mode Exit fullscreen mode
// test/asyncUtils.test.js - Promise style
const { expect } = require('chai');
const { fetchUserById } = require('../src/asyncUtils');

describe('fetchUserById() - Promise style', function () {

  it('should resolve with the user for a valid ID', function () {
    return fetchUserById(1).then(user => {
      expect(user).to.deep.equal({ id: 1, name: 'Alice' });
    });
  });

  it('should reject for an invalid ID', function () {
    return fetchUserById(999)
      .then(() => {
        throw new Error('Expected rejection');
      })
      .catch(err => {
        expect(err.message).to.equal('User 999 not found');
      });
  });

});
Enter fullscreen mode Exit fullscreen mode

Testing with async/await

The async/await pattern is cleaner and is the recommended approach for all new tests.

// test/asyncUtils.test.js - async/await style
const { expect } = require('chai');
const { fetchUserById } = require('../src/asyncUtils');

describe('fetchUserById() - async/await style', function () {

  it('should resolve with the user for a valid ID', async function () {
    const user = await fetchUserById(1);
    expect(user).to.deep.equal({ id: 1, name: 'Alice' });
  });

  it('should reject for an invalid ID', async function () {
    try {
      await fetchUserById(999);
      expect.fail('Should have thrown an error');
    } catch (err) {
      expect(err.message).to.equal('User 999 not found');
    }
  });

});
Enter fullscreen mode Exit fullscreen mode

A cleaner way to test for expected rejections is with chai-as-promised, but you can also use the try/catch pattern shown above without any extra dependencies.

Testing callbacks with done()

For callback-style async code, Mocha provides a done parameter. Call done() when the test is complete or done(error) to fail the test.

// src/legacyApi.js
function fetchData(callback) {
  setTimeout(() => {
    callback(null, { status: 'ok', data: [1, 2, 3] });
  }, 50);
}

function fetchWithError(callback) {
  setTimeout(() => {
    callback(new Error('Network failure'));
  }, 50);
}

module.exports = { fetchData, fetchWithError };
Enter fullscreen mode Exit fullscreen mode
// test/legacyApi.test.js
const { expect } = require('chai');
const { fetchData, fetchWithError } = require('../src/legacyApi');

describe('Legacy Callback API', function () {

  it('should return data via callback', function (done) {
    fetchData(function (err, result) {
      expect(err).to.be.null;
      expect(result.status).to.equal('ok');
      expect(result.data).to.deep.equal([1, 2, 3]);
      done();
    });
  });

  it('should return error via callback', function (done) {
    fetchWithError(function (err) {
      expect(err).to.be.an('error');
      expect(err.message).to.equal('Network failure');
      done();
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Important: If you declare done as a parameter but never call it, Mocha will time out and fail the test. If your test is synchronous, do not accept the done parameter.

Testing timers with Sinon fake timers

When testing code that uses setTimeout or setInterval, you do not want to wait for real time to pass. Sinon's fake timers let you control the clock.

// npm install --save-dev sinon

// src/debounce.js
function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

module.exports = { debounce };
Enter fullscreen mode Exit fullscreen mode
// test/debounce.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const { debounce } = require('../src/debounce');

describe('debounce()', function () {
  let clock;

  beforeEach(function () {
    clock = sinon.useFakeTimers();
  });

  afterEach(function () {
    clock.restore();
  });

  it('should not call the function before the delay', function () {
    const spy = sinon.spy();
    const debounced = debounce(spy, 300);

    debounced();
    clock.tick(200);

    expect(spy.called).to.be.false;
  });

  it('should call the function after the delay', function () {
    const spy = sinon.spy();
    const debounced = debounce(spy, 300);

    debounced();
    clock.tick(300);

    expect(spy.calledOnce).to.be.true;
  });

  it('should reset the timer on subsequent calls', function () {
    const spy = sinon.spy();
    const debounced = debounce(spy, 300);

    debounced();
    clock.tick(200);
    debounced(); // resets the timer
    clock.tick(200);

    expect(spy.called).to.be.false;

    clock.tick(100);
    expect(spy.calledOnce).to.be.true;
  });

  it('should pass arguments to the debounced function', function () {
    const spy = sinon.spy();
    const debounced = debounce(spy, 100);

    debounced('hello', 42);
    clock.tick(100);

    expect(spy.calledWith('hello', 42)).to.be.true;
  });

});
Enter fullscreen mode Exit fullscreen mode

Hooks: setup and teardown

Mocha provides four lifecycle hooks for setting up and cleaning up test state.

Hook When it runs
before() Once before all tests in the describe block
after() Once after all tests in the describe block
beforeEach() Before every individual test in the block
afterEach() After every individual test in the block

Basic hook usage

describe('Database Operations', function () {

  before(function () {
    // Runs once: connect to test database
    console.log('Connecting to test database...');
  });

  after(function () {
    // Runs once: disconnect from test database
    console.log('Disconnecting from test database...');
  });

  beforeEach(function () {
    // Runs before every test: seed test data
    console.log('Seeding test data...');
  });

  afterEach(function () {
    // Runs after every test: clean up data
    console.log('Cleaning up test data...');
  });

  it('should insert a record', function () {
    // test code
  });

  it('should fetch a record', function () {
    // test code
  });

});
Enter fullscreen mode Exit fullscreen mode

Nested describe blocks with hooks

Hooks in nested describe blocks run in a predictable order. Outer hooks run before inner hooks.

describe('Outer', function () {
  before(function () { console.log('1 - outer before'); });
  beforeEach(function () { console.log('2 - outer beforeEach'); });

  it('outer test', function () { console.log('3 - outer test'); });

  describe('Inner', function () {
    before(function () { console.log('4 - inner before'); });
    beforeEach(function () { console.log('5 - inner beforeEach'); });

    it('inner test', function () { console.log('6 - inner test'); });

    afterEach(function () { console.log('7 - inner afterEach'); });
    after(function () { console.log('8 - inner after'); });
  });

  afterEach(function () { console.log('9 - outer afterEach'); });
  after(function () { console.log('10 - outer after'); });
});

// Execution order for "inner test":
// 1 - outer before
// 4 - inner before
// 2 - outer beforeEach
// 5 - inner beforeEach
// 6 - inner test
// 7 - inner afterEach
// 9 - outer afterEach
// 8 - inner after
// 10 - outer after
Enter fullscreen mode Exit fullscreen mode

Realistic database setup and teardown example

Here is a practical example of using hooks with an in-memory database:

// test/userRepository.test.js
const { expect } = require('chai');

// Simulated in-memory database
class InMemoryDB {
  constructor() { this.data = new Map(); this.nextId = 1; }
  insert(record) {
    const id = this.nextId++;
    this.data.set(id, { ...record, id });
    return this.data.get(id);
  }
  findById(id) { return this.data.get(id) || null; }
  findAll() { return Array.from(this.data.values()); }
  deleteById(id) { return this.data.delete(id); }
  clear() { this.data.clear(); this.nextId = 1; }
}

// Module under test
class UserRepository {
  constructor(db) { this.db = db; }

  createUser(name, email) {
    if (!name || !email) throw new Error('Name and email are required');
    return this.db.insert({ name, email, createdAt: new Date().toISOString() });
  }

  getUserById(id) { return this.db.findById(id); }
  getAllUsers() { return this.db.findAll(); }
  deleteUser(id) { return this.db.deleteById(id); }
}

describe('UserRepository', function () {
  let db;
  let repo;

  before(function () {
    // Create the database once for all tests
    db = new InMemoryDB();
  });

  beforeEach(function () {
    // Clear all data before each test for isolation
    db.clear();
    repo = new UserRepository(db);
  });

  describe('createUser()', function () {
    it('should create a user with an auto-generated ID', function () {
      const user = repo.createUser('Alice', 'alice@example.com');
      expect(user).to.have.property('id', 1);
      expect(user).to.have.property('name', 'Alice');
      expect(user).to.have.property('email', 'alice@example.com');
      expect(user).to.have.property('createdAt');
    });

    it('should throw if name is missing', function () {
      expect(() => repo.createUser('', 'test@example.com')).to.throw('Name and email are required');
    });

    it('should throw if email is missing', function () {
      expect(() => repo.createUser('Alice', '')).to.throw('Name and email are required');
    });
  });

  describe('getUserById()', function () {
    it('should return the user for a valid ID', function () {
      repo.createUser('Alice', 'alice@example.com');
      const user = repo.getUserById(1);
      expect(user).to.not.be.null;
      expect(user.name).to.equal('Alice');
    });

    it('should return null for a non-existent ID', function () {
      const user = repo.getUserById(999);
      expect(user).to.be.null;
    });
  });

  describe('getAllUsers()', function () {
    it('should return an empty array when no users exist', function () {
      expect(repo.getAllUsers()).to.deep.equal([]);
    });

    it('should return all created users', function () {
      repo.createUser('Alice', 'alice@example.com');
      repo.createUser('Bob', 'bob@example.com');
      const users = repo.getAllUsers();
      expect(users).to.have.lengthOf(2);
    });
  });

  describe('deleteUser()', function () {
    it('should remove the user', function () {
      repo.createUser('Alice', 'alice@example.com');
      repo.deleteUser(1);
      expect(repo.getUserById(1)).to.be.null;
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Mocking and stubbing with Sinon

Real applications depend on external services: databases, APIs, file systems, third-party libraries. Unit tests should not call these dependencies. Sinon provides three tools for replacing dependencies during tests.

Spies, stubs, and mocks explained

Spy -- Wraps a function and records how it was called (arguments, return values, call count) without changing its behavior. Use spies when you want to verify that a function was called correctly but still want it to execute.

Stub -- Replaces a function with a controlled implementation. The original function never runs. Use stubs to prevent side effects (network calls, database writes) and to control return values.

Mock -- Like a stub but with built-in expectations. Mocks verify that specific calls were made with specific arguments. They are less commonly used because stubs + assertions achieve the same thing more clearly.

Sinon spies

// npm install --save-dev sinon

const sinon = require('sinon');
const { expect } = require('chai');

describe('Sinon Spies', function () {

  it('should track function calls', function () {
    const callback = sinon.spy();

    callback('hello');
    callback('world');

    expect(callback.callCount).to.equal(2);
    expect(callback.firstCall.args[0]).to.equal('hello');
    expect(callback.secondCall.args[0]).to.equal('world');
    expect(callback.calledWith('hello')).to.be.true;
  });

  it('should spy on an existing method', function () {
    const calculator = {
      add(a, b) { return a + b; }
    };

    const spy = sinon.spy(calculator, 'add');

    const result = calculator.add(2, 3);

    expect(result).to.equal(5); // original function still runs
    expect(spy.calledOnce).to.be.true;
    expect(spy.calledWith(2, 3)).to.be.true;

    spy.restore(); // always restore spies
  });

});
Enter fullscreen mode Exit fullscreen mode

Sinon stubs

// src/orderService.js
class OrderService {
  constructor(paymentGateway, emailService) {
    this.paymentGateway = paymentGateway;
    this.emailService = emailService;
  }

  async placeOrder(order) {
    const paymentResult = await this.paymentGateway.charge(order.total, order.cardToken);

    if (!paymentResult.success) {
      throw new Error(`Payment failed: ${paymentResult.error}`);
    }

    await this.emailService.sendConfirmation(order.email, order.id);

    return {
      orderId: order.id,
      status: 'confirmed',
      transactionId: paymentResult.transactionId,
    };
  }
}

module.exports = OrderService;
Enter fullscreen mode Exit fullscreen mode
// test/orderService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const OrderService = require('../src/orderService');

describe('OrderService', function () {
  let paymentGateway;
  let emailService;
  let orderService;

  beforeEach(function () {
    // Create stubs for dependencies
    paymentGateway = {
      charge: sinon.stub(),
    };
    emailService = {
      sendConfirmation: sinon.stub(),
    };
    orderService = new OrderService(paymentGateway, emailService);
  });

  describe('placeOrder()', function () {

    it('should process a successful order', async function () {
      // Arrange: configure stubs
      paymentGateway.charge.resolves({
        success: true,
        transactionId: 'txn_123',
      });
      emailService.sendConfirmation.resolves();

      const order = {
        id: 'order_1',
        total: 49.99,
        cardToken: 'tok_visa',
        email: 'alice@example.com',
      };

      // Act
      const result = await orderService.placeOrder(order);

      // Assert
      expect(result).to.deep.equal({
        orderId: 'order_1',
        status: 'confirmed',
        transactionId: 'txn_123',
      });
      expect(paymentGateway.charge.calledOnce).to.be.true;
      expect(paymentGateway.charge.calledWith(49.99, 'tok_visa')).to.be.true;
      expect(emailService.sendConfirmation.calledOnce).to.be.true;
      expect(emailService.sendConfirmation.calledWith('alice@example.com', 'order_1')).to.be.true;
    });

    it('should throw when payment fails', async function () {
      paymentGateway.charge.resolves({
        success: false,
        error: 'Insufficient funds',
      });

      const order = {
        id: 'order_2',
        total: 999.99,
        cardToken: 'tok_declined',
        email: 'bob@example.com',
      };

      try {
        await orderService.placeOrder(order);
        expect.fail('Should have thrown');
      } catch (err) {
        expect(err.message).to.equal('Payment failed: Insufficient funds');
      }

      // Verify email was NOT sent for failed payments
      expect(emailService.sendConfirmation.called).to.be.false;
    });

  });

});
Enter fullscreen mode Exit fullscreen mode

Stubbing module-level functions

When you need to stub a function that is imported at the module level, you can use sinon.stub() on the module's exports:

// src/weatherService.js
const axios = require('axios');

async function getCurrentTemperature(city) {
  const response = await axios.get(`https://api.weather.example.com/current?city=${city}`);
  return response.data.temperature;
}

module.exports = { getCurrentTemperature };
Enter fullscreen mode Exit fullscreen mode
// test/weatherService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const axios = require('axios');
const { getCurrentTemperature } = require('../src/weatherService');

describe('weatherService', function () {

  afterEach(function () {
    sinon.restore(); // restores ALL stubs, spies, and mocks
  });

  it('should return the temperature from the API', async function () {
    sinon.stub(axios, 'get').resolves({
      data: { temperature: 22.5 },
    });

    const temp = await getCurrentTemperature('London');

    expect(temp).to.equal(22.5);
    expect(axios.get.calledOnce).to.be.true;
    expect(axios.get.firstCall.args[0]).to.include('city=London');
  });

  it('should propagate API errors', async function () {
    sinon.stub(axios, 'get').rejects(new Error('Network error'));

    try {
      await getCurrentTemperature('Paris');
      expect.fail('Should have thrown');
    } catch (err) {
      expect(err.message).to.equal('Network error');
    }
  });

});
Enter fullscreen mode Exit fullscreen mode

Restoring stubs

Stubs and spies modify global objects. If you do not restore them after each test, the modifications leak into subsequent tests and cause unpredictable failures.

There are three ways to restore:

// Option 1: Restore individual stubs
afterEach(function () {
  myStub.restore();
});

// Option 2: Restore everything at once (recommended)
afterEach(function () {
  sinon.restore();
});

// Option 3: Use a Sinon sandbox (useful for complex setups)
describe('with sandbox', function () {
  let sandbox;

  beforeEach(function () {
    sandbox = sinon.createSandbox();
  });

  afterEach(function () {
    sandbox.restore();
  });

  it('uses sandbox stubs', function () {
    const stub = sandbox.stub(someObject, 'method');
    // sandbox.restore() cleans everything up
  });
});
Enter fullscreen mode Exit fullscreen mode

Always use sinon.restore() in an afterEach hook. This is the single most common mistake with Sinon. If you forget, a stub from one test will bleed into the next, causing confusing failures that only happen when tests run in a specific order.

HTTP API testing with Supertest

Supertest lets you make HTTP requests to your Express application without starting a real server. It is the standard tool for testing Express routes.

// npm install --save-dev supertest

// src/app.js
const express = require('express');
const app = express();

app.use(express.json());

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
];

// Middleware: simple auth check
function requireAuth(req, res, next) {
  const token = req.headers.authorization;
  if (!token || token !== 'Bearer valid-token') {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
}

app.get('/api/users', function (req, res) {
  res.json(users);
});

app.get('/api/users/:id', function (req, res) {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

app.post('/api/users', requireAuth, function (req, res) {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }
  const newUser = { id: users.length + 1, name, email, role: 'user' };
  users.push(newUser);
  res.status(201).json(newUser);
});

app.put('/api/users/:id', requireAuth, function (req, res) {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  Object.assign(user, req.body);
  res.json(user);
});

app.delete('/api/users/:id', requireAuth, function (req, res) {
  const index = users.findIndex(u => u.id === parseInt(req.params.id));
  if (index === -1) return res.status(404).json({ error: 'User not found' });
  users.splice(index, 1);
  res.status(204).send();
});

module.exports = app;
Enter fullscreen mode Exit fullscreen mode
// test/app.test.js
const { expect } = require('chai');
const request = require('supertest');
const app = require('../src/app');

describe('User API', function () {

  describe('GET /api/users', function () {
    it('should return all users', async function () {
      const res = await request(app)
        .get('/api/users')
        .expect(200);

      expect(res.body).to.be.an('array');
      expect(res.body.length).to.be.at.least(1);
      expect(res.body[0]).to.have.all.keys('id', 'name', 'email', 'role');
    });
  });

  describe('GET /api/users/:id', function () {
    it('should return a single user by ID', async function () {
      const res = await request(app)
        .get('/api/users/1')
        .expect(200);

      expect(res.body.name).to.equal('Alice');
      expect(res.body.email).to.equal('alice@example.com');
    });

    it('should return 404 for a non-existent user', async function () {
      const res = await request(app)
        .get('/api/users/999')
        .expect(404);

      expect(res.body.error).to.equal('User not found');
    });
  });

  describe('POST /api/users', function () {
    it('should create a new user when authenticated', async function () {
      const res = await request(app)
        .post('/api/users')
        .set('Authorization', 'Bearer valid-token')
        .send({ name: 'Charlie', email: 'charlie@example.com' })
        .expect(201);

      expect(res.body).to.have.property('id');
      expect(res.body.name).to.equal('Charlie');
      expect(res.body.role).to.equal('user');
    });

    it('should return 401 without authentication', async function () {
      await request(app)
        .post('/api/users')
        .send({ name: 'Dave', email: 'dave@example.com' })
        .expect(401);
    });

    it('should return 400 when name is missing', async function () {
      const res = await request(app)
        .post('/api/users')
        .set('Authorization', 'Bearer valid-token')
        .send({ email: 'test@example.com' })
        .expect(400);

      expect(res.body.error).to.equal('Name and email are required');
    });
  });

  describe('PUT /api/users/:id', function () {
    it('should update an existing user', async function () {
      const res = await request(app)
        .put('/api/users/1')
        .set('Authorization', 'Bearer valid-token')
        .send({ name: 'Alice Updated' })
        .expect(200);

      expect(res.body.name).to.equal('Alice Updated');
    });
  });

  describe('DELETE /api/users/:id', function () {
    it('should delete a user and return 204', async function () {
      await request(app)
        .delete('/api/users/2')
        .set('Authorization', 'Bearer valid-token')
        .expect(204);
    });

    it('should return 404 when deleting a non-existent user', async function () {
      await request(app)
        .delete('/api/users/999')
        .set('Authorization', 'Bearer valid-token')
        .expect(404);
    });
  });

});
Enter fullscreen mode Exit fullscreen mode

Supertest chains are readable and expressive. You can set headers with .set(), send bodies with .send(), and assert on status codes with .expect(). For complex response body assertions, use Chai's expect on res.body.

Code coverage with NYC/Istanbul

Code coverage tells you which lines, branches, and functions your tests actually exercise. It does not tell you if your tests are good -- you can have 100% coverage with useless assertions -- but it does reveal which code is completely untested.

Setup

Install NYC (the command-line interface for Istanbul):

// Terminal:
// npm install --save-dev nyc
Enter fullscreen mode Exit fullscreen mode

Add a coverage script to package.json:

// package.json
{
  "scripts": {
    "test": "mocha",
    "test:coverage": "nyc mocha"
  },
  "nyc": {
    "reporter": ["text", "html", "lcov"],
    "include": ["src/**/*.js"],
    "exclude": ["test/**"],
    "all": true,
    "check-coverage": true,
    "branches": 80,
    "lines": 80,
    "functions": 80,
    "statements": 80
  }
}
Enter fullscreen mode Exit fullscreen mode

Reading coverage reports

Run npm run test:coverage and you will see a table like this:

// Terminal output:
// -------------|---------|----------|---------|---------|
// File         | % Stmts | % Branch | % Funcs | % Lines |
// -------------|---------|----------|---------|---------|
// All files    |   92.31 |    85.71 |     100 |   92.31 |
//  math.js     |     100 |      100 |     100 |     100 |
//  userUtils.js|   88.89 |    66.67 |     100 |   88.89 |
// -------------|---------|----------|---------|---------|
Enter fullscreen mode Exit fullscreen mode
  • Statements -- Percentage of executable statements that were run.
  • Branches -- Percentage of branch paths (if/else, ternary, switch) that were taken.
  • Functions -- Percentage of functions that were called at least once.
  • Lines -- Percentage of lines that were executed.

NYC also generates an HTML report in the coverage/ directory. Open coverage/index.html in a browser to see line-by-line highlighting of covered and uncovered code.

Setting coverage thresholds

The check-coverage option in the NYC configuration above will cause npm run test:coverage to exit with a non-zero code if coverage falls below 80%. This is useful for CI/CD pipelines where you want to prevent merging untested code.

Choose thresholds that are realistic for your project. Starting at 80% is common. Some teams push toward 90%+ for critical business logic while accepting lower coverage for glue code and configuration.

CI/CD integration

Tests are only valuable if they run automatically on every push and pull request. Here is a complete GitHub Actions workflow for running Mocha tests with coverage.

GitHub Actions workflow

// .github/workflows/test.yml
//
// name: Tests
//
// on:
//   push:
//     branches: [main]
//   pull_request:
//     branches: [main]
//
// jobs:
//   test:
//     runs-on: ubuntu-latest
//
//     strategy:
//       matrix:
//         node-version: [18, 20, 22]
//
//     steps:
//       - name: Checkout code
//         uses: actions/checkout@v4
//
//       - name: Setup Node.js ${{ matrix.node-version }}
//         uses: actions/setup-node@v4
//         with:
//           node-version: ${{ matrix.node-version }}
//           cache: 'npm'
//
//       - name: Install dependencies
//         run: npm ci
//
//       - name: Run tests with coverage
//         run: npm run test:coverage
//
//       - name: Upload coverage report
//         if: matrix.node-version == 20
//         uses: actions/upload-artifact@v4
//         with:
//           name: coverage-report
//           path: coverage/
Enter fullscreen mode Exit fullscreen mode

This workflow does the following:

  1. Triggers on pushes to main and on all pull requests targeting main.
  2. Tests against three Node.js versions (18, 20, 22) to catch compatibility issues.
  3. Uses npm ci instead of npm install for deterministic, faster installs from package-lock.json.
  4. Runs tests with coverage so the build fails if tests fail or coverage drops below thresholds.
  5. Uploads the coverage report as a build artifact so you can download and inspect it from the GitHub Actions UI.

Failing builds on test failures

This happens automatically. If any Mocha test fails, npx mocha exits with code 1, and the GitHub Actions step fails. If coverage is below your thresholds, nyc exits with code 1.

You can enforce this as a required status check in your repository settings:

  1. Go to Settings > Branches > Branch protection rules.
  2. Enable Require status checks to pass before merging.
  3. Select the test job from the workflow.

Now no one can merge a pull request with failing tests.

Best practices

One assertion per test (when practical)

Each test should verify one specific behavior. When a test with five assertions fails, you have to read all five to figure out which one broke. When a test with one assertion fails, the test name tells you exactly what is wrong.

// Avoid: multiple unrelated assertions
it('should format the user correctly', function () {
  const user = formatUser('Alice', 'Smith', 'ALICE@EXAMPLE.COM');
  expect(user.fullName).to.equal('Alice Smith');
  expect(user.email).to.equal('alice@example.com');
  expect(user.initials).to.equal('AS');
});

// Prefer: separate tests for separate behaviors
it('should combine first and last name into fullName', function () {
  const user = formatUser('Alice', 'Smith', 'ALICE@EXAMPLE.COM');
  expect(user.fullName).to.equal('Alice Smith');
});

it('should lowercase the email', function () {
  const user = formatUser('Alice', 'Smith', 'ALICE@EXAMPLE.COM');
  expect(user.email).to.equal('alice@example.com');
});

it('should generate uppercase initials', function () {
  const user = formatUser('Alice', 'Smith', 'ALICE@EXAMPLE.COM');
  expect(user.initials).to.equal('AS');
});
Enter fullscreen mode Exit fullscreen mode

That said, this is a guideline, not a law. When assertions are tightly related (like checking that a returned object has the right shape), grouping them in one test with deep.equal is perfectly fine.

Descriptive test names

Test names should describe the behavior being verified, not the implementation. They should read like specifications.

// Avoid: vague or implementation-focused names
it('should work', function () { /* ... */ });
it('test add function', function () { /* ... */ });
it('calls the database', function () { /* ... */ });

// Prefer: behavior-focused names
it('should return the sum of two positive integers', function () { /* ... */ });
it('should throw a TypeError when input is not a string', function () { /* ... */ });
it('should return an empty array when no users match the filter', function () { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

A good heuristic: if the test fails, can someone understand what broke just by reading the test name in the output? If yes, the name is good.

The AAA pattern (Arrange, Act, Assert)

Structure every test into three phases:

it('should apply a 10% discount for orders over $100', function () {
  // Arrange: set up the inputs and expected values
  const cart = new ShoppingCart();
  cart.addItem('Laptop Stand', 120, 1);

  // Act: call the function under test
  const total = cart.getTotalWithDiscount();

  // Assert: verify the result
  expect(total).to.equal(108);
});
Enter fullscreen mode Exit fullscreen mode

This pattern keeps tests readable and consistent. When scanning a test, you immediately know where the setup ends and the verification begins.

Test isolation

Every test should be independent. It should not depend on the order tests run or on state left behind by a previous test. Use beforeEach to set up fresh state and afterEach to clean up.

// Bad: tests depend on shared mutable state
let count = 0;

it('should increment', function () {
  count++;
  expect(count).to.equal(1);
});

it('should be at 1', function () {
  expect(count).to.equal(1); // fails if tests run in different order
});

// Good: each test creates its own state
describe('Counter', function () {
  let count;

  beforeEach(function () {
    count = 0;
  });

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

  it('should increment', function () {
    count++;
    expect(count).to.equal(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

Avoid testing implementation details

Test what a function does, not how it does it. If you test implementation details, your tests break every time you refactor -- even when the behavior is unchanged.

// Bad: testing implementation details
it('should use Array.prototype.filter internally', function () {
  const spy = sinon.spy(Array.prototype, 'filter');
  filterActiveUsers(users);
  expect(spy.calledOnce).to.be.true;
  spy.restore();
});

// Good: testing the behavior
it('should return only active users', function () {
  const result = filterActiveUsers([
    { name: 'Alice', active: true },
    { name: 'Bob', active: false },
  ]);
  expect(result).to.have.lengthOf(1);
  expect(result[0].name).to.equal('Alice');
});
Enter fullscreen mode Exit fullscreen mode

Common mistakes

Not restoring stubs

This is the number one source of flaky tests in Mocha + Sinon projects. A stub that is not restored will persist across tests and affect other test files.

// MISTAKE: forgetting to restore
describe('API tests', function () {
  it('stubs axios', function () {
    sinon.stub(axios, 'get').resolves({ data: {} });
    // test runs... but the stub is never restored
  });

  it('another test that uses axios', function () {
    // This test STILL uses the stub from the previous test!
    // axios.get is still stubbed, which is almost certainly wrong.
  });
});

// FIX: always restore in afterEach
describe('API tests', function () {
  afterEach(function () {
    sinon.restore();
  });

  it('stubs axios', function () {
    sinon.stub(axios, 'get').resolves({ data: {} });
    // ...
  });

  it('another test', function () {
    // axios.get is back to normal
  });
});
Enter fullscreen mode Exit fullscreen mode

Shared mutable state between tests

When tests share a mutable object and one test modifies it, the change affects all subsequent tests. This creates order-dependent test failures that are extremely hard to debug.

// MISTAKE: shared mutable object
const config = { retries: 3, timeout: 5000 };

it('test with modified config', function () {
  config.retries = 0; // modifies the shared object
  // ...
});

it('test that expects default config', function () {
  expect(config.retries).to.equal(3); // FAILS because previous test changed it
});

// FIX: create fresh state in beforeEach
describe('Config tests', function () {
  let config;

  beforeEach(function () {
    config = { retries: 3, timeout: 5000 };
  });

  it('test with modified config', function () {
    config.retries = 0;
    expect(config.retries).to.equal(0);
  });

  it('test that expects default config', function () {
    expect(config.retries).to.equal(3); // works because config was recreated
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing framework internals instead of your code

Sometimes developers accidentally write tests that verify Mocha or Chai behavior rather than their own code.

// MISTAKE: testing that Chai works
it('should be true', function () {
  expect(true).to.be.true; // this tests Chai, not your code
});

// MISTAKE: testing that JavaScript works
it('should add numbers', function () {
  expect(2 + 2).to.equal(4); // this tests JavaScript, not your code
});

// CORRECT: testing your own function
it('should calculate the correct total', function () {
  const cart = new ShoppingCart();
  cart.addItem('Widget', 9.99, 3);
  expect(cart.getTotal()).to.equal(29.97);
});
Enter fullscreen mode Exit fullscreen mode

Forgetting to handle async

If you write an async test but forget to return the promise or use async/await, the test will pass even when the assertion inside fails. Mocha considers the test done as soon as the synchronous code finishes, and the async assertion failure happens after Mocha has already moved on.

// MISTAKE: async assertion runs after test completes
it('should fetch data', function () {
  // Missing 'return' - this test ALWAYS passes!
  fetchUserById(1).then(user => {
    expect(user.name).to.equal('WRONG NAME'); // this failure is never caught
  });
});

// FIX option 1: return the promise
it('should fetch data', function () {
  return fetchUserById(1).then(user => {
    expect(user.name).to.equal('Alice');
  });
});

// FIX option 2: use async/await (recommended)
it('should fetch data', async function () {
  const user = await fetchUserById(1);
  expect(user.name).to.equal('Alice');
});
Enter fullscreen mode Exit fullscreen mode

This is a particularly insidious mistake because the test appears to pass. You think everything is fine, but the assertion is literally never running. If you see a test with a .then() inside and no return or await, it is almost certainly broken.

Summary

Mocha and Chai give you a flexible, modular testing stack where you control every piece. Mocha handles test structure, lifecycle hooks, and async coordination. Chai provides expressive assertions in the style you prefer. Sinon handles mocking and stubbing. Supertest handles HTTP testing. NYC provides coverage reporting.

The key takeaways:

  • Use expect style assertions for consistency and compatibility across all value types.
  • Always use sinon.restore() in afterEach to prevent stub leakage between tests.
  • Use async/await for async tests -- it is the cleanest and least error-prone pattern.
  • Isolate every test with beforeEach to prevent shared state bugs.
  • Test behavior, not implementation -- your tests should survive refactoring.
  • Run tests in CI on every push to catch regressions before they reach production.

Whether you are adding tests to an existing Node.js backend or starting a new project from scratch, this guide gives you everything you need to write reliable, maintainable unit tests with Mocha and Chai.

Further Reading

Frequently Asked Questions

What is the difference between Mocha and Chai?

Mocha is a test runner/framework that provides structure (describe, it blocks), lifecycle hooks (before, after), and async support. Chai is an assertion library that provides readable ways to verify expected outcomes (expect, should, assert styles). Mocha runs your tests; Chai checks whether results are correct.

Is Mocha still relevant in 2026?

Yes. While Jest and Vitest have gained popularity, Mocha remains widely used for its flexibility, minimal opinion on project structure, and compatibility with any assertion or mocking library. It is especially popular in Node.js backend testing.

How do I test async functions with Mocha?

Mocha supports three async patterns: returning a Promise from the test, using async/await syntax, or calling the done() callback. The async/await approach is recommended for readability.

What is the best assertion style in Chai?

Chai offers three styles: expect (BDD), should (BDD), and assert (TDD). The expect style is most popular because it works consistently across environments and does not modify Object.prototype like should does.


Originally published at aicodereview.cc

Top comments (0)