Skip to content

Software Testing & QA Tools

Mocha and Chai

Mocha is a JavaScript test runner with no built-in assertion library or mocking support — it defines how tests are structured (describe/it, lifecycle hooks) and executed, and leaves the rest to whatever you install alongside it. Chai is the assertion library almost every Mocha project pairs it with, offering three interchangeable syntaxes (assert, expect, should) for writing the actual pass/fail checks. Together they reproduce roughly what Jest bundles as one package, at the cost of an extra dependency and a bit of wiring — most Mocha+Chai projects also add Sinon for spies, stubs, and mocks, since neither Mocha nor Chai provides that on its own.

Why it matters

Predates Jest and remains the default in many existing Node codebases
Plenty of backend services, CLIs, and libraries — especially ones with no React or DOM involvement — standardized on Mocha before Jest existed or specifically to avoid Jest's React-oriented defaults, and haven't had a reason to migrate.
Splitting runner, assertions, and mocking means each piece is swappable
A team can replace Chai with Should.js, or add Sinon only where mocking is actually needed, without touching how tests are structured or run — a flexibility Jest's bundled design doesn't offer.
Runs natively in Node and, via a bundler, directly in a real browser
Before Jest's jsdom abstraction became common, this was one of Mocha's clearest advantages for testing code that had to behave correctly in an actual browser environment, not a simulated one.
Widely used for non-React Node services where Jest's defaults add nothing
A CLI tool or an Express API has no use for React-flavored snapshot testing or a bundled DOM shim, so a leaner runner-plus-assertions stack avoids paying for features that never get used.

Two libraries, one job split in half

Mocha owns test structure and lifecycle: describe blocks for grouping, it for individual tests, before/after and beforeEach/afterEach hooks, per-test timeouts, retries, and reporting output. Chai owns assertions only — it has no concept of a test runner and can't execute anything by itself. The split is deliberate: Mocha's own documentation states that it allows you to use any assertion library you wish — anything that throws an Error on failure works — so it can pair with whichever library a project prefers, Chai included but not required.

Picking a Chai style

assert.equal(actual, expected) is TDD-style and closely mirrors Node's built-in assert module, just with richer failure messages and more assertion types. expect(actual).to.equal(expected) is BDD-style, chainable, and the most commonly recommended style today because failures read close to plain English. should extends Object.prototype so foo.should.equal(bar) reads even more naturally, but it does this by monkey-patching every object's prototype, which is fragile around null/undefined values and is generally the least recommended of the three styles in newer code.

JavaScript
const { expect } = require('chai');
const sum = require('./sum');

describe('sum', () => {
  it('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).to.equal(3);
  });

  it('resolves an async value', async () => {
    const result = await Promise.resolve(sum(2, 2));
    expect(result).to.equal(4);
  });
});

Async tests, and the return-or-await trap

A Mocha test can accept a done callback, return a promise, or use async/await — but if a test contains an assertion inside a promise chain and the function doesn't return or await that promise, Mocha considers the test finished (and passing) before the assertion ever runs. This is a genuinely common source of false-positive tests: the code looks correct, the promise's rejection would have failed the test, but Mocha never saw it because the test function had already returned.

Where mocking fits

Neither Mocha nor Chai provides spies, stubs, or mocks — Sinon.js is the conventional third piece for that, and sinon-chai plugs Sinon's assertions into Chai's chainable syntax so you can write expect(spy).to.have.been.calledOnce instead of checking spy.callCount manually. This is one more package to install and version-match compared to Jest, where jest.fn is already there.

Mistakes people make here

Not returning or awaiting a promise-based assertion inside it()
Mocha only fails a test when it observes a thrown error or a rejected promise from the test function itself — an assertion buried inside an un-awaited .then() can fail silently while the outer test function returns and reports as passing.
Mixing Chai's should style into a codebase that otherwise uses expect
Both work, but should monkey-patches Object.prototype globally the moment it's required, which means its side effects apply everywhere in the process, not just in files that intentionally opted in — mixing styles makes a suite harder to read and occasionally interacts oddly with libraries that check for specific prototype shapes.
Using arrow functions for describe() or it() blocks that need Mocha's this
Mocha binds a special this to regular function() test callbacks for things like this.timeout(5000) or this.skip() — an arrow function doesn't have its own this, so it silently inherits the outer scope's instead, and the call either errors or does nothing.
Assuming test files run in isolated processes the way Jest's do
By default, every file in a single Mocha run executes in the same Node process, so global state, monkey-patched prototypes (like Chai's should), or an unhandled exception in one file can bleed into unrelated tests in the same run — Jest's default per-file process isolation doesn't apply here.

Strengths and trade-offs

Where it is strong

  • Full control over which assertion and mocking libraries to use, with no bundled opinions forcing a particular style.
  • Lighter and generally faster to start than Jest for plain Node suites, since there's no required transform layer unless you add one for TypeScript.
  • Runs the same test files in Node or, via a bundler, in an actual browser — useful for code that has to behave correctly in a real browser environment.
  • A mature ecosystem of reporters and configuration options (.mocharc, many output formats) built up over many years of production use.

The trade-offs

  • Requires assembling and version-matching multiple packages — Mocha, Chai, usually Sinon, and a separate coverage tool like nyc — instead of one.
  • No built-in mocking or snapshot testing; both require adding a separate library rather than being available out of the box.
  • Shared-process execution means one test's leaked global state or unhandled exception can affect unrelated tests in the same run, unlike Jest's per-file isolation.
  • TypeScript and ESM configuration is more manual than Jest's now-standard presets, since Mocha itself doesn't ship a transform pipeline.

Who needs this

Teams testing Node services, CLIs, or libraries who want to choose their own assertion and mocking stack rather than accept Jest's bundle, and anyone maintaining an existing Mocha+Chai codebase that predates Jest's dominance.

Questions about mocha and chai

Do I need Sinon if I'm already using Mocha and Chai?
Not required, but yes if you need spies, stubs, or mocks — Chai only handles assertions, it has no concept of a mock function, so Sinon (often paired with sinon-chai for readable assertions) is the conventional third piece.
Which Chai assertion style should a new project use?
expect() is the most commonly recommended today for its readability and chainability. assert() suits teams that prefer a style closer to Node's built-in assert module. should is generally discouraged in new code because it monkey-patches every object's prototype.
Can Mocha do snapshot testing like Jest?
Not natively — there's no snapshot feature built into Mocha or Chai. Third-party plugins can add snapshot-style assertions, but it isn't a first-class, zero-config feature the way it is in Jest.
Is Mocha still actively maintained?
Yes — it's hosted under the OpenJS Foundation alongside Jest and remains a widely used, independently maintained project, still receiving updates and used across a large share of Node.js packages in the npm registry.

The primary source

Related concepts

← All concept guides