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.
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.
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
- You are starting a new Node-only project: node:test plus node:assert is built into the runtime, needs no dependencies, and covers describe/it, hooks, concurrency, mocking and coverage
- Your code is TypeScript or JSX: Mocha has no transform pipeline, so you bolt on tsx or ts-node and manage two configs, while Vitest reads your existing Vite or tsconfig setup and just runs
- You want mocking, spies, snapshots and coverage without assembling them: Jest and Vitest include all of it and Mocha includes none of it
- You need parallel runs and exclusive tests together: --parallel makes .only an error, is mutually exclusive with --file and --sort, and rejects the progress, markdown and json-stream reporters
- Dependency count matters for supply-chain review: Mocha 11 pulls roughly twenty runtime dependencies including yargs, glob, chokidar and workerpool
- You want to upgrade once and be done: version 12 is already in release candidates with breaking changes (the package becomes ESM, yargs is replaced by Node's util.parseArgs), so a migration is queued behind this one
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 testThe 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 dotWatch 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
| Package | Registry | Pick it when |
|---|---|---|
| vitest | npm | You want TypeScript, JSX and ESM to work with no extra config, plus mocking, snapshots and coverage in one package |
| jest | npm | You want the batteries-included runner with module mocking and snapshots, and your project is already on Babel or React Native tooling |
| ava | npm | You want tests isolated in separate processes and running concurrently by default, with no shared globals |
| tap | npm | You want TAP output, built-in coverage and process isolation from a runner that also brings its own assertions |