mrkeyoor.com_
Thu 06 Aug 10:56 UTC
npmTestingupdated 06 Aug 2026

mocha

Mocha is a test runner for Node and the browser. It finds your test files, executes describe and it blocks in order, runs before, beforeEach, after and afterEach hooks around them, applies timeouts, and prints results through a reporter. That is the whole job. It deliberately ships no assertion library, no mocking, no stubbing, no snapshots and no coverage, so a real setup is Mocha plus node:assert or Chai, plus Sinon if you need fakes, plus c8 or nyc for coverage. Everything is configurable through CLI flags or a .mocharc file: which interface you write tests in (bdd, tdd, exports, qunit), which reporter prints them, whether files run in parallel worker processes, and what gets loaded before the suite starts. It has been around since 2011, it is one of the most depended-on packages on npm, and it is maintained by volunteers under the OpenJS Foundation.

Verdict

Still a dependable runner, and the right call if you already have a Mocha suite or genuinely want to pick your own assertion and mocking stack. For a new Node project in 2026, node:test costs nothing and Vitest does more, so choosing Mocha fresh needs a reason beyond familiarity.

API stability4/5describe, it and the hook functions have not changed in a decade and old suites keep running across majors. The CLI is where movement happens: version 12 release candidates change the package to ESM and swap the argument parser, so flag parsing edge cases and programmatic require() usage will need attention.
Docs4/5mochajs.org documents every flag, every interface and every reporter, and covers parallel mode's restrictions and root hook plugins honestly. It is one very long page, the ESM and TypeScript sections assume you already know what a loader is, and the API reference for programmatic use is thin.
Maintenance3/511.8.0 landed 2 August 2026 with the repo pushed within a day, and a v12 line is in release candidates, so work is happening. The README states the project is maintained exclusively by volunteers, and 201 open issues (249 counting PRs) reflect a backlog larger than the maintainer time available.
Ecosystem5/515M installs a week, 22.9k stars, and one of the most depended-upon packages on npm. Every assertion library, reporter, CI service and coverage tool documents a Mocha path, and mocha-specific reporters exist for basically every output format anyone has wanted.

Use it if

  • You already have a large Mocha suite: it still runs, it still gets releases, and migrating thousands of tests to another runner buys you very little
  • You want to choose your own assertion and mocking libraries instead of adopting one framework's opinion about all three
  • You need to run the same test files in Node and in a real browser, which Mocha supports directly with a browser build
  • Your tests are integration or end-to-end style against real services, where a runner's transform pipeline and module mocking do not matter and stability does
  • You want a runner whose behaviour you can fully describe: no transform layer, no auto-mocking, no magic globals beyond the interface you selected
Skip it if

Setup reality

npm install --save-dev mocha gets you a runner that finds ./test/*.spec.js and nothing else useful, because Mocha has no assertions. Add node:assert (free) or Chai, and Sinon if you need fakes. Node 18.18, 20.9 or 21.1 and newer is required. Default extensions are js, cjs and mjs, so TypeScript files are invisible until you pass --extension ts and a loader such as tsx or ts-node/register through --require. The default timeout is 2000ms, which is fine for units and constantly wrong for anything touching a database, and the only way to change it per test is this.timeout(), which means your callbacks cannot be arrow functions: an arrow function has no Mocha context and this.timeout, this.retries and this.skip are all undefined inside one. Put settings in a .mocharc.json, .mocharc.yml or a mocha key in package.json rather than a growing npm script. If you use ESM, --require still works because Mocha imports rather than requires when the target is a module, but hooks that rely on file order need the root hook plugin form instead of top-level hooks in a required file. And Mocha does not exit on its own if something keeps the event loop alive: an open database handle means a hung CI job until you add --exit or close the handle in an after hook.

Patterns

Get a suite runninginstall-and-run

npm install --save-dev mocha

// package.json
{
  "scripts": {
    "test": "mocha",
    "test:watch": "mocha --watch"
  }
}

# runs ./test/*.{js,cjs,mjs} by default
npm test

The default spec glob is ./test/ one level deep. Nested folders need --recursive or an explicit glob quoted so your shell does not expand it: mocha 'test/**/*.spec.js'.

A test with no extra assertion librarywrite-a-test

import assert from 'node:assert/strict'
import { total } from '../src/cart.js'

describe('total', function () {
  it('sums line items', function () {
    assert.equal(total([{ price: 100, qty: 2 }]), 200)
  })

  it('rejects a negative quantity', function () {
    assert.throws(() => total([{ price: 100, qty: -1 }]), /quantity/)
  })
})

node:assert/strict is enough for most tests and costs nothing. Reach for Chai when you want expect-style chains or plugin matchers, not by reflex.

Async tests, and the callback formasync-tests

it('loads the user', async function () {
  const user = await getUser(1)
  assert.equal(user.id, 1)
})

it('emits done once', function (done) {
  emitter.once('ready', () => done())
  emitter.start()
})

Pick one style per test. Declaring the done parameter and also returning a promise makes Mocha fail the test with an explicit error, because it cannot tell which signal to wait for.

Set up and tear down around testshooks-and-fixtures

describe('orders api', function () {
  let server, db

  before(async function () {
    db = await startDatabase()
    server = await startServer(db)
  })

  beforeEach(async function () {
    await db.truncate('orders')
  })

  after(async function () {
    await server.close()
    await db.close()
  })
})

A throw inside before skips every test in that describe and reports the hook as failed, so a suite showing zero failures and zero passes usually means a broken before. Closing handles in after is what lets Mocha exit without --exit.

Move flags out of the npm scriptconfig-file

// .mocharc.json
{
  "spec": ["test/**/*.spec.js"],
  "recursive": true,
  "timeout": 10000,
  "require": ["./test/setup.js"],
  "forbid-only": false,
  "reporter": "spec"
}

Mocha also reads .mocharc.yml, .mocharc.cjs and a mocha key in package.json, and stops at the first one it finds rather than merging them. CLI flags override the file.

Change the timeout for a slow testtimeouts-and-retries

describe('report export', function () {
  this.timeout(30000)

  it('renders the quarterly PDF', function () {
    this.retries(2)
    return renderReport()
  })

  it('is fast', function () {
    this.timeout(500)
  })
})

This is why Mocha examples use function () and not arrow functions: an arrow has no Mocha context, so this.timeout is not a function. retries only reruns failing tests, never hooks, so a flaky beforeEach still fails the suite.

Run one test locally without shipping itfocus-and-skip

describe.only('billing', function () {
  it.skip('handles refunds', function () {})
  it('charges the card', function () {})
})

# in CI, make a stray .only fail the build
mocha --forbid-only

--forbid-only in CI is the whole point of this pattern. Note that .only throws an error under --parallel, so a suite that uses both needs the flag combination checked.

Run ESM tests with a shared setup moduleesm-and-setup-file

// package.json
{ "type": "module" }

// test/setup.js
export const mochaHooks = {
  async beforeAll() {
    globalThis.db = await startDatabase()
  },
  async afterAll() {
    await globalThis.db.close()
  },
}

# .mocharc.json
{ "require": ["./test/setup.js"] }

--require imports the file rather than requiring it when it is an ES module, so this works. Exporting a mochaHooks object (a root hook plugin) is the supported way to share hooks; plain top-level before() calls in a required ESM file do not register reliably and break under --parallel.

Run TypeScript teststypescript-tests

npm install --save-dev mocha tsx @types/mocha

// .mocharc.json
{
  "extension": ["ts"],
  "spec": ["test/**/*.spec.ts"],
  "require": ["tsx"]
}

Both pieces are needed: without --extension ts Mocha will not even see the files, and without the loader Node cannot parse them. Neither tsx nor ts-node type-checks by default, so keep a separate tsc --noEmit step in CI.

Run test files in worker processesparallel-mode

mocha --parallel --jobs 4

// test/setup.js, required in .mocharc.json
export const mochaHooks = {
  beforeEach() { /* runs in every worker */ },
}
export const mochaGlobalSetup = async () => { /* runs once, main process */ }

Each worker is a separate process, so nothing in module scope is shared and file order is non-deterministic. --parallel is rejected alongside --file and --sort, .only becomes an error, and mochaGlobalSetup and mochaGlobalTeardown are skipped inside workers.

Rerun on change while developingwatch-mode

mocha --watch --watch-files 'src/**/*.js,test/**/*.js' --reporter dot

Watch mode reloads changed files but keeps the process alive, so module-level state that a test mutated stays mutated between runs. If results differ between watch mode and a fresh run, that is usually the cause.

Add coverage and a CI-safe invocationcoverage-and-ci

npm install --save-dev c8

// package.json
{
  "scripts": {
    "test": "mocha",
    "test:ci": "c8 --check-coverage --lines 80 mocha --forbid-only --fail-zero --reporter spec"
  }
}

Mocha produces no coverage of its own; c8 wraps the process and reads V8's built-in data. --fail-zero turns an accidentally empty run into a failure instead of a green build that tested nothing.

Alternatives

PackageRegistryPick it when
vitestnpmYou want TypeScript, JSX and ESM to work with no extra config, plus mocking, snapshots and coverage in one package
jestnpmYou want the batteries-included runner with module mocking and snapshots, and your project is already on Babel or React Native tooling
avanpmYou want tests isolated in separate processes and running concurrently by default, with no shared globals
tapnpmYou want TAP output, built-in coverage and process isolation from a runner that also brings its own assertions