← Back to DevBytes

Migrating from Jest to Mocha: Step-by-Step Guide

Introduction: Understanding the Jest to Mocha Migration

Migrating from Jest to Mocha involves moving your JavaScript or Node.js test suite from Jest's all-in-one testing framework to Mocha's more modular, minimalist test runner. Jest bundles assertions, mocking, coverage, and test running into a single package with a zero-config philosophy. Mocha, by contrast, is a flexible test runner that requires you to pair it with external libraries for assertions (like Chai), mocking (like Sinon), and sometimes coverage (like nyc/c8). This migration is not just about swapping packages—it's about adopting a different testing architecture that gives you granular control over every layer of your test stack.

Why Migrate from Jest to Mocha?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The decision to migrate usually stems from one or more of these practical considerations:

Key Differences Between Jest and Mocha

Before diving into the migration steps, it's essential to understand the architectural differences that will shape your refactoring work:

Step-by-Step Migration Guide

Step 1: Install Mocha and Companion Libraries

Begin by installing Mocha along with the assertion library (Chai), mocking library (Sinon), and coverage tool (nyc or c8). Remove Jest from your project once the migration is complete, but keep it installed during the transition so you can run both test suites in parallel for validation.

# Install Mocha and its ecosystem
npm install --save-dev mocha chai sinon nyc

# Optional but recommended for better diffs
npm install --save-dev chai-jest-diff

# When migration is complete, remove Jest
npm uninstall jest @types/jest jest-environment-node

If you use TypeScript, you'll also need the type declarations:

npm install --save-dev @types/mocha @types/chai @types/sinon

Step 2: Configure the Test Runner

Create a Mocha configuration file. Mocha supports .mocharc.yml, .mocharc.json, .mocharc.js, or inline configuration in package.json. Here's a typical .mocharc.yml setup:

# .mocharc.yml
recursive: true
require:
  - chai/register-expect
  - chai/register-should
timeout: 5000
reporter: spec
spec:
  - test/**/*.test.js
  - test/**/*.spec.js
watch-files:
  - src/**/*.js
  - test/**/*.js
extension:
  - js
  - ts
node-option:
  - experimental-specifier-resolution=node

For TypeScript projects, you'll need to configure a loader. The simplest approach is using ts-node or the newer tsx:

# .mocharc.yml (TypeScript)
recursive: true
require:
  - ts-node/register
  - chai/register-expect
timeout: 5000
reporter: spec
spec:
  - test/**/*.test.ts
  - test/**/*.spec.ts
extension:
  - ts

Update your package.json scripts to point to Mocha:

{
  "scripts": {
    "test": "mocha",
    "test:watch": "mocha --watch",
    "test:coverage": "nyc mocha",
    "test:ci": "nyc --reporter=lcov mocha --reporter=mocha-junit-reporter"
  }
}

Step 3: Convert Test Syntax (Describe, It, and Assertions)

The describe and it blocks remain structurally identical. Both Jest and Mocha use the same BDD interface. The primary change is in assertions. You'll replace Jest's expect with Chai's expect or assert.

Here's a Jest test before migration:

// math.test.js (Jest)
const { add, divide } = require('./math');

describe('math utilities', () => {
  it('should add two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('should throw on division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });

  it('should return a value close to pi', () => {
    expect(3.14159).toBeCloseTo(Math.PI, 2);
  });
});

After migration to Mocha + Chai:

// math.test.js (Mocha + Chai)
const { expect } = require('chai');
const { add, divide } = require('./math');

describe('math utilities', () => {
  it('should add two numbers', () => {
    expect(add(2, 3)).to.equal(5);
  });

  it('should throw on division by zero', () => {
    expect(() => divide(10, 0)).to.throw('Division by zero');
  });

  it('should return a value close to pi', () => {
    expect(3.14159).to.be.closeTo(Math.PI, 2);
  });
});

Key assertion mappings to remember:

Step 4: Replace Jest-Specific APIs

Mocking Functions

Jest's jest.fn() becomes Sinon's sinon.stub() or sinon.spy(). Here's a side-by-side comparison:

// Jest: Function mocking
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
mockFn.mockImplementation((x) => x * 2);

// Mocha + Sinon: Function mocking
const sinon = require('sinon');
const stub = sinon.stub();
stub.returns(42);
stub.callsFake((x) => x * 2);

// Jest: Spying on existing methods
const spy = jest.spyOn(console, 'log');
spy.mockImplementation(() => {});

// Mocha + Sinon: Spying on existing methods
const spy = sinon.spy(console, 'log');
// Restore after test
console.log.restore();

Complete test example with Sinon mocks:

// userService.test.js (Mocha + Chai + Sinon)
const { expect } = require('chai');
const sinon = require('sinon');
const UserService = require('./userService');
const apiClient = require('./apiClient');

describe('UserService', () => {
  let sandbox;

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

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

  it('should fetch user by id', async () => {
    const mockResponse = { id: 1, name: 'Alice' };
    sandbox.stub(apiClient, 'get').resolves(mockResponse);

    const result = await UserService.fetchUser(1);

    expect(result).to.deep.equal(mockResponse);
    expect(apiClient.get).to.have.been.calledWith('/users/1');
  });

  it('should handle errors gracefully', async () => {
    sandbox.stub(apiClient, 'get').rejects(new Error('Network error'));

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

Mocking Modules

Jest's automatic module mocking (jest.mock('./module')) has no direct equivalent in Mocha. You have several strategies:

// Jest: Module mocking
jest.mock('./config', () => ({
  apiUrl: 'http://test.example.com',
  debug: true
}));

// Mocha Strategy 1: Use proxyquire (CommonJS)
const proxyquire = require('proxyquire');
const configMock = { apiUrl: 'http://test.example.com', debug: true };
const app = proxyquire('./app', { './config': configMock });

// Mocha Strategy 2: Use testdouble (supports ESM and CJS)
const td = require('testdouble');
const configMock = td.replace('./config', {
  apiUrl: 'http://test.example.com',
  debug: true
});
const app = require('./app'); // Now uses mocked config

Install proxyquire or testdouble based on your module system:

npm install --save-dev proxyquire
# or
npm install --save-dev testdouble

Timer Mocking

Jest's fake timers map directly to Sinon's fake timers:

// Jest: Timer control
jest.useFakeTimers();
jest.advanceTimersByTime(1000);

// Mocha + Sinon: Timer control
let clock;
beforeEach(() => {
  clock = sinon.useFakeTimers();
});
afterEach(() => {
  clock.restore();
});
// Advance time by 1000ms
clock.tick(1000);

// For async timers, use clock.tickAsync (Sinon 14+)
await clock.tickAsync(1000);

Full timer test example:

// debounce.test.js (Mocha + Sinon)
const { expect } = require('chai');
const sinon = require('sinon');
const { debounce } = require('./debounce');

describe('debounce', () => {
  let clock;
  let callback;

  beforeEach(() => {
    clock = sinon.useFakeTimers();
    callback = sinon.spy();
  });

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

  it('should call the function after the specified delay', () => {
    const debouncedFn = debounce(callback, 300);
    debouncedFn();
    debouncedFn();
    debouncedFn();

    expect(callback).not.to.have.been.called;

    clock.tick(300);
    expect(callback).to.have.been.calledOnce;
  });
});

Step 5: Handling Snapshots

Mocha does not have built-in snapshot testing. You have two practical paths:

Option A: Replace snapshots with explicit assertions — This is the most maintainable long-term approach. Convert each snapshot assertion into explicit property checks:

// Jest: Snapshot test
it('should render user card correctly', () => {
  const html = renderUserCard({ name: 'Bob', age: 30 });
  expect(html).toMatchSnapshot();
});

// Mocha: Explicit assertion replacement
it('should render user card correctly', () => {
  const html = renderUserCard({ name: 'Bob', age: 30 });
  expect(html).to.contain('
'); expect(html).to.contain('Bob'); expect(html).to.contain('30'); });

Option B: Use a community snapshot library — If you genuinely need snapshot testing, use mocha-snapshots or a custom serializer:

npm install --save-dev mocha-snapshots

// Test file
const snap = require('mocha-snapshots');

it('should render user card', async () => {
  const html = renderUserCard({ name: 'Bob', age: 30 });
  await snap(html); // Creates/compares against snapshot file
});

Note that community snapshot solutions may not be as polished as Jest's built-in system. Evaluate whether explicit assertions would serve your team better before adding this dependency.

Step 6: Coverage and Reporting

Jest's --coverage flag is replaced by running Mocha through nyc (or c8 for ESM-heavy projects):

# Jest-style coverage
jest --coverage

# Mocha equivalent
nyc mocha

# Or with c8 (better ESM support)
c8 mocha

Create an .nycrc.yml configuration file to mirror your Jest coverage settings:

# .nycrc.yml
reporter:
  - text
  - html
  - lcov
include:
  - src/**/*.js
  - lib/**/*.js
exclude:
  - test/**/*
  - node_modules/**/*
all: true
lines: 80
branches: 80
functions: 80
statements: 80
watermarks:
  lines: [80, 95]
  branches: [80, 95]

For CI pipeline reporting, Mocha works well with JUnit-style reporters:

npm install --save-dev mocha-junit-reporter

# Run with JUnit reporter
mocha --reporter=mocha-junit-reporter --reporter-options=output=test-results.xml

Best Practices for a Smooth Migration

Example: Full Migration of a Sample Test File

Here's a complete before-and-after migration of a realistic test file to tie everything together:

Before (Jest):

// orders.test.js — Jest version
const { processOrder } = require('./orders');
const { inventory } = require('./inventory');
const { sendEmail } = require('./email');

jest.mock('./inventory');
jest.mock('./email');

describe('processOrder', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should deduct inventory and send email', async () => {
    inventory.isAvailable.mockReturnValue(true);
    inventory.deduct.mockResolvedValue({ remaining: 5 });
    sendEmail.mockResolvedValue(true);

    const result = await processOrder({ item: 'widget', qty: 2 });

    expect(result.success).toBe(true);
    expect(inventory.isAvailable).toHaveBeenCalledWith('widget', 2);
    expect(inventory.deduct).toHaveBeenCalledWith('widget', 2);
    expect(sendEmail).toHaveBeenCalledTimes(1);
  });

  it('should fail if item not available', async () => {
    inventory.isAvailable.mockReturnValue(false);

    const result = await processOrder({ item: 'widget', qty: 2 });

    expect(result.success).toBe(false);
    expect(result.error).toContain('not available');
    expect(inventory.deduct).not.toHaveBeenCalled();
    expect(sendEmail).not.toHaveBeenCalled();
  });
});

After (Mocha + Chai + Sinon + proxyquire):

// orders.test.js — Mocha version
const { expect } = require('chai');
const sinon = require('sinon');
const proxyquire = require('proxyquire');

describe('processOrder', () => {
  let sandbox;
  let inventoryMock;
  let emailMock;
  let processOrder;

  beforeEach(() => {
    sandbox = sinon.createSandbox();

    inventoryMock = {
      isAvailable: sandbox.stub(),
      deduct: sandbox.stub()
    };

    emailMock = {
      sendEmail: sandbox.stub()
    };

    processOrder = proxyquire('./orders', {
      './inventory': inventoryMock,
      './email': emailMock
    });
  });

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

  it('should deduct inventory and send email', async () => {
    inventoryMock.isAvailable.returns(true);
    inventoryMock.deduct.resolves({ remaining: 5 });
    emailMock.sendEmail.resolves(true);

    const result = await processOrder({ item: 'widget', qty: 2 });

    expect(result.success).to.equal(true);
    expect(inventoryMock.isAvailable).to.have.been.calledWith('widget', 2);
    expect(inventoryMock.deduct).to.have.been.calledWith('widget', 2);
    expect(emailMock.sendEmail).to.have.been.calledOnce;
  });

  it('should fail if item not available', async () => {
    inventoryMock.isAvailable.returns(false);

    const result = await processOrder({ item: 'widget', qty: 2 });

    expect(result.success).to.equal(false);
    expect(result.error).to.contain('not available');
    expect(inventoryMock.deduct).not.to.have.been.called;
    expect(emailMock.sendEmail).not.to.have.been.called;
  });
});

Conclusion

Migrating from Jest to Mocha is a deliberate architectural choice that trades Jest's integrated convenience for Mocha's modular flexibility. The process involves replacing assertions with Chai, mocking with Sinon, module mocking with proxyquire or testdouble, and coverage with nyc or c8. By following the incremental migration strategy outlined above—converting one test file at a time while maintaining both test suites in parallel—you can achieve a smooth transition without disrupting your CI pipeline. The investment pays off in faster, more predictable test runs and a testing stack that you control piece by piece. Remember to lean on Sinon sandboxes for clean test isolation, standardize on expect-style assertions for readability, and replace snapshot tests with explicit, maintainable assertions whenever possible. With these practices in place, your new Mocha-based test suite will be both robust and pleasant to work with.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles