Introduction

Mocha and Chai form one of the most popular testing combinations in the JavaScript ecosystem. Mocha provides a flexible testing framework with excellent async support, while Chai offers expressive assertion styles that make tests readable and maintainable. Together, they create a powerful testing solution for Node.js and browser-based applications. For React component testing, consider Jest and Testing Library as a modern alternative.

Why Mocha and Chai?

Mocha Advantages

  • Flexibility: Works with any assertion library (Chai, Should.js, Expect.js)
  • Async support: First-class support for Promises, async/await, and callbacks
  • Rich reporting: Multiple built-in reporters and extensible reporter system
  • Browser support: Runs both in Node.js and browsers
  • Serial execution: Tests run serially, allowing precise async flow control

Chai Advantages

  • Multiple assertion styles: BDD (expect/should) and TDD (assert)
  • Readable syntax: Natural language assertions
  • Extensible: Rich plugin ecosystem
  • Error messages: Clear, helpful error messages

Installation and Setup

Basic Installation

npm install --save-dev mocha chai

Project Structure

project/
├── src/
│   └── calculator.js
├── test/
│   ├── unit/
│   │   └── calculator.test.js
│   └── integration/
│       └── api.test.js
├── package.json
└── .mocharc.json

Configuration (.mocharc.json)

{
  "require": ["test/setup.js"],
  "spec": ["test/**/*.test.js"],
  "timeout": 5000,
  "reporter": "spec",
  "recursive": true,
  "exit": true
}

Package.json Scripts

{
  "scripts": {
    "test": "mocha",
    "test:watch": "mocha --watch",
    "test:coverage": "nyc mocha",
    "test:unit": "mocha test/unit/**/*.test.js",
    "test:integration": "mocha test/integration/**/*.test.js"
  }
}

Chai Assertion Styles

1. Expect (BDD Style)

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

describe('Expect Style', () => {
  it('should demonstrate expect assertions', () => {
    const name = 'John';
    const age = 30;
    const hobbies = ['reading', 'coding'];
    const user = { name: 'John', active: true };

    // Equality
    expect(name).to.equal('John');
    expect(age).to.be.a('number');

    // Deep equality for objects/arrays
    expect(user).to.deep.equal({ name: 'John', active: true });

    // Length and inclusion
    expect(hobbies).to.have.lengthOf(2);
    expect(hobbies).to.include('coding');

    // Property existence
    expect(user).to.have.property('name');
    expect(user).to.have.property('name', 'John');

    // Boolean checks
    expect(user.active).to.be.true;
    expect(undefined).to.be.undefined;
    expect(null).to.be.null;

    // Negation
    expect(name).to.not.equal('Jane');
  });
});

2. Should (BDD Style)

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

describe('Should Style', () => {
  it('should demonstrate should assertions', () => {
    const name = 'John';
    const hobbies = ['reading', 'coding'];

    name.should.equal('John');
    name.should.be.a('string');
    hobbies.should.have.lengthOf(2);
    hobbies.should.include('coding');
  });
});

3. Assert (TDD Style)

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

describe('Assert Style', () => {
  it('should demonstrate assert assertions', () => {
    const name = 'John';
    const user = { name: 'John', age: 30 };

    assert.equal(name, 'John');
    assert.typeOf(name, 'string');
    assert.lengthOf(name, 4);
    assert.property(user, 'name');
    assert.propertyVal(user, 'name', 'John');
    assert.deepEqual(user, { name: 'John', age: 30 });
  });
});

Real-World Testing Examples

Testing a Calculator Module

src/calculator.js:

class Calculator {
  add(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new TypeError('Arguments must be numbers');
    }
    return a + b;
  }

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

  async asyncMultiply(a, b) {
    return new Promise((resolve) => {
      setTimeout(() => resolve(a * b), 100);
    });
  }
}

module.exports = Calculator;

test/unit/calculator.test.js:

const { expect } = require('chai');
const Calculator = require('../../src/calculator');

describe('Calculator', () => {
  let calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  describe('#add()', () => {
    it('should add two positive numbers', () => {
      const result = calculator.add(5, 3);
      expect(result).to.equal(8);
    });

    it('should add negative numbers', () => {
      expect(calculator.add(-5, -3)).to.equal(-8);
    });

    it('should throw TypeError for non-number arguments', () => {
      expect(() => calculator.add('5', 3)).to.throw(TypeError, 'Arguments must be numbers');
    });
  });

  describe('#divide()', () => {
    it('should divide two numbers', () => {
      expect(calculator.divide(10, 2)).to.equal(5);
    });

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

  describe('#asyncMultiply()', () => {
    it('should multiply two numbers asynchronously', async () => {
      const result = await calculator.asyncMultiply(4, 5);
      expect(result).to.equal(20);
    });
  });
});

Testing API Service

For comprehensive API testing strategies, see our API testing mastery guide.

src/userService.js:

const axios = require('axios');

class UserService {
  constructor(baseURL) {
    this.client = axios.create({ baseURL });
  }

  async getUser(id) {
    const response = await this.client.get(`/users/${id}`);
    return response.data;
  }

  async createUser(userData) {
    const response = await this.client.post('/users', userData);
    return response.data;
  }
}

module.exports = UserService;

test/unit/userService.test.js:

const { expect } = require('chai');
const sinon = require('sinon');
const UserService = require('../../src/userService');
const axios = require('axios');

describe('UserService', () => {
  let userService;
  let axiosStub;

  beforeEach(() => {
    userService = new UserService('https://api.example.com');
    axiosStub = sinon.stub(axios, 'create').returns({
      get: sinon.stub(),
      post: sinon.stub()
    });
  });

  afterEach(() => {
    sinon.restore();
  });

  describe('#getUser()', () => {
    it('should fetch user by id', async () => {
      const mockUser = { id: 1, name: 'John' };
      userService.client.get.resolves({ data: mockUser });

      const user = await userService.getUser(1);

      expect(user).to.deep.equal(mockUser);
      expect(userService.client.get.calledWith('/users/1')).to.be.true;
    });

    it('should handle API errors', async () => {
      userService.client.get.rejects(new Error('Network error'));

      try {
        await userService.getUser(1);
        expect.fail('Should have thrown error');
      } catch (error) {
        expect(error.message).to.equal('Network error');
      }
    });
  });
});

Mocha Hooks

Hook Types

describe('Hooks Example', () => {
  before(() => {
    // Runs once before all tests in this block
    console.log('Setup: before all tests');
  });

  after(() => {
    // Runs once after all tests in this block
    console.log('Teardown: after all tests');
  });

  beforeEach(() => {
    // Runs before each test in this block
    console.log('Setup: before each test');
  });

  afterEach(() => {
    // Runs after each test in this block
    console.log('Teardown: after each test');
  });

  it('test 1', () => {
    expect(true).to.be.true;
  });

  it('test 2', () => {
    expect(false).to.be.false;
  });
});

Async Hooks

describe('Async Hooks', () => {
  before(async () => {
    // Setup database connection
    await database.connect();
  });

  after(async () => {
    // Close database connection
    await database.disconnect();
  });

  beforeEach(async () => {
    // Clear database before each test
    await database.clear();
  });
});

Async Testing Patterns

describe('Async/Await Pattern', () => {
  it('should fetch user data', async () => {
    const user = await fetchUser(123);
    expect(user.name).to.equal('John');
  });

  it('should handle errors', async () => {
    try {
      await fetchUser(999);
      expect.fail('Should have thrown');
    } catch (error) {
      expect(error.message).to.include('not found');
    }
  });
});

2. Promises

describe('Promise Pattern', () => {
  it('should fetch user data', () => {
    return fetchUser(123).then(user => {
      expect(user.name).to.equal('John');
    });
  });

  it('should handle errors', () => {
    return fetchUser(999).catch(error => {
      expect(error.message).to.include('not found');
    });
  });
});

3. Callbacks (Legacy)

describe('Callback Pattern', () => {
  it('should fetch user data', (done) => {
    fetchUser(123, (error, user) => {
      if (error) return done(error);
      expect(user.name).to.equal('John');
      done();
    });
  });
});

Chai Plugins

chai-http (HTTP Testing)

npm install --save-dev chai-http
const chai = require('chai');
const chaiHttp = require('chai-http');
const app = require('../src/app');

chai.use(chaiHttp);
const { expect } = chai;

describe('API Endpoints', () => {
  it('GET /api/users should return users', (done) => {
    chai.request(app)
      .get('/api/users')
      .end((err, res) => {
        expect(res).to.have.status(200);
        expect(res.body).to.be.an('array');
        expect(res.body).to.have.lengthOf.at.least(1);
        done();
      });
  });

  it('POST /api/users should create user', async () => {
    const res = await chai.request(app)
      .post('/api/users')
      .send({ name: 'John', email: 'john@example.com' });

    expect(res).to.have.status(201);
    expect(res.body).to.have.property('id');
    expect(res.body.name).to.equal('John');
  });
});

chai-as-promised (Promise Assertions)

npm install --save-dev chai-as-promised
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');

chai.use(chaiAsPromised);
const { expect } = chai;

describe('Promise Testing', () => {
  it('should resolve with user data', () => {
    return expect(fetchUser(123)).to.eventually.have.property('name', 'John');
  });

  it('should reject with error', () => {
    return expect(fetchUser(999)).to.be.rejectedWith('User not found');
  });

  it('should fulfill', () => {
    return expect(Promise.resolve('success')).to.be.fulfilled;
  });
});

chai-spies (Spies and Stubs)

npm install --save-dev chai-spies
const chai = require('chai');
const spies = require('chai-spies');

chai.use(spies);
const { expect } = chai;

describe('Spy Testing', () => {
  it('should call callback', () => {
    const callback = chai.spy();
    const processor = (fn) => fn();

    processor(callback);

    expect(callback).to.have.been.called();
    expect(callback).to.have.been.called.once;
  });
});

Mocha Reporters

Built-in Reporters

# Spec (default) - hierarchical view
mocha --reporter spec

# Dot matrix - minimal output
mocha --reporter dot

# JSON - machine-readable output
mocha --reporter json > results.json

# HTML - browser-friendly output
mocha --reporter html > results.html

# TAP - Test Anything Protocol
mocha --reporter tap

Custom Reporter Example

// custom-reporter.js
function CustomReporter(runner) {
  const passes = [];
  const failures = [];

  runner.on('pass', (test) => {
    passes.push(test);
  });

  runner.on('fail', (test, err) => {
    failures.push({ test, err });
  });

  runner.on('end', () => {
    console.log(`\n✓ ${passes.length} passing`);
    console.log(`✗ ${failures.length} failing\n`);
  });
}

module.exports = CustomReporter;

Usage: mocha --reporter ./custom-reporter.js

Code Coverage with NYC

Installation

npm install --save-dev nyc

Configuration (.nycrc.json)

{
  "all": true,
  "include": ["src/**/*.js"],
  "exclude": ["test/**", "**/*.test.js"],
  "reporter": ["html", "text", "lcov"],
  "check-coverage": true,
  "lines": 80,
  "functions": 80,
  "branches": 80,
  "statements": 80
}

Running Coverage

nyc mocha

# With threshold enforcement
nyc --check-coverage --lines 90 mocha

Best Practices

1. Descriptive Test Names

// Bad
it('test 1', () => {});

// Good
it('should return 404 when user does not exist', () => {});

2. Arrange-Act-Assert Pattern

it('should calculate total price with discount', () => {
  // Arrange
  const cart = new ShoppingCart();
  cart.addItem({ price: 100 });
  const discount = 0.1;

  // Act
  const total = cart.calculateTotal(discount);

  // Assert
  expect(total).to.equal(90);
});

3. One Assertion Per Test

// Avoid multiple unrelated assertions
it('should validate user', () => {
  expect(user.name).to.equal('John');
  expect(user.age).to.equal(30);
  expect(user.email).to.include('@');
});

// Better: separate tests
describe('User Validation', () => {
  it('should have correct name', () => {
    expect(user.name).to.equal('John');
  });

  it('should have correct age', () => {
    expect(user.age).to.equal(30);
  });

  it('should have valid email', () => {
    expect(user.email).to.include('@');
  });
});

4. Avoid Test Interdependence

// Bad: tests depend on execution order
describe('Bad Example', () => {
  let userId;

  it('should create user', async () => {
    const user = await createUser({ name: 'John' });
    userId = user.id; // Shared state
  });

  it('should fetch user', async () => {
    const user = await fetchUser(userId); // Depends on previous test
    expect(user.name).to.equal('John');
  });
});

// Good: independent tests
describe('Good Example', () => {
  it('should create user', async () => {
    const user = await createUser({ name: 'John' });
    expect(user).to.have.property('id');
  });

  it('should fetch user', async () => {
    const created = await createUser({ name: 'John' });
    const fetched = await fetchUser(created.id);
    expect(fetched.name).to.equal('John');
  });
});

5. Use Test Fixtures

// test/fixtures/users.js
module.exports = {
  validUser: {
    name: 'John Doe',
    email: 'john@example.com',
    age: 30
  },
  invalidUser: {
    name: '',
    email: 'invalid-email'
  }
};

// test/user.test.js
const fixtures = require('./fixtures/users');

describe('User Validation', () => {
  it('should accept valid user', () => {
    expect(validateUser(fixtures.validUser)).to.be.true;
  });
});

CI/CD Integration

Integrate Mocha tests into your CI/CD pipeline following best practices from our CI/CD guide for testers.

GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Generate coverage
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

Conclusion

Mocha and Chai provide a powerful, flexible testing foundation for JavaScript projects. Mocha’s async-first design and flexible architecture combine perfectly with Chai’s expressive assertion styles to create readable, maintainable tests. By following best practices—descriptive test names, proper test isolation, and comprehensive coverage—you can build a robust test suite that catches bugs early and serves as living documentation for your codebase. For framework-specific testing, explore Jest for React or Cypress for E2E testing.

Start with simple unit tests, gradually add integration tests, leverage plugins for specialized testing needs, and integrate coverage reporting to ensure your application remains reliable and maintainable.