Mocha Interview Questions and Answers
Last updated:
Check out 40 of the most common Mocha interview questions, then take an AI-powered practice interview
Q1What is Mocha and how does it compare to Jest?
BasicFundamentals
Answer
Mocha is a feature-rich JavaScript test framework that runs on Node.js and in the browser. Released by TJ Holowaychuk in 2011, it pioneered the BDD-style `describe`/`it` syntax that nearly every JS test framework now uses. The defining philosophy is composability: Mocha provides the test runner, structure, and hooks, but you bring your own assertion library (Chai, Node's `assert`), your own mocking library (Sinon), and your own coverage tool (c8, nyc).
Jest, by contrast, is opinionated and batteries-included, assertions, mocks, snapshots, and coverage all in one package. Choose Mocha when you want fine-grained control over your test stack or you're maintaining a codebase that already uses it; choose Jest when you want a single npm install to get everything. In practice the split shows up at upgrade time: when Chai 5 shipped as ESM-only in 2024, CommonJS suites pinned `chai@4` and left the runner completely alone, whereas the same break in an all-in-one tool moves the whole toolchain at once.
Mocha also runs unchanged in a real browser through `mocha.setup('bdd')` and a script tag, which Jest only approximates with jsdom. What you give up is equally real: there is no `jest.mock()` equivalent, so module-level mocking needs `proxyquire`, `esmock` or plain dependency injection, and there are no built-in snapshots and no changed-file watch filtering. Version-wise, Mocha v11 requires Node 18.18+ and treats `type: module` packages as first-class. The senior follow-up is almost always 'what do you lose by picking Mocha', and the credible answer is module mocking and zero-config coverage, traded for being able to upgrade one piece of the stack at a time on a service whose suite nobody wants to rewrite.
Key Points
- Unopinionated, pair with Chai/Sinon/c8 of your choice
- BDD-style describe/it syntax (which Jest copied)
- Runs in Node.js and browser
- Mocha v11 (2025) ships native ESM and improved TypeScript support
Q2How do you write a basic Mocha test using describe and it?
BasicStructure
Answer
`describe` groups related tests into a suite, and `it` defines an individual test case. Suites can be nested arbitrarily to mirror module structure, typically one outermost `describe` per file matching the module under test, with nested `describe`s for each method or behavior. The string passed to `describe`/`it` is purely human-readable, Mocha concatenates them in test reports (e.g. 'User > create > sets default role').
Convention: phrase `describe` names as nouns ('User', 'createUser') and `it` names as behaviors ('returns an id', 'rejects on invalid email'), when read together, the report becomes a specification of expected behavior. Mocha also exposes `context` as an alias for `describe` and `specify` as an alias for `it`, for teams that prefer BDD-flavored vocabulary. Two structural details separate people who have only read the docs from people who have debugged a suite.
First, `describe` callbacks execute immediately when Mocha loads the file, during the suite-collection phase, while `it` bodies run later during the execution phase. That is why `await` inside a `describe` body accomplishes nothing, why a variable assigned at suite level is still `undefined` when the test runs, and why fixtures belong in `before`/`beforeEach` rather than inline. Second, the concatenated full title (`'User createUser returns an object with an id'`) is the string `--grep` matches against, so an anchored pattern like `--grep '^createUser'` silently matches zero tests and Mocha still exits 0.
Third, every test in a file shares one Node process by default, so a `describe` that mutates a module singleton leaks into its siblings. Interviewers often ask what happens with two identically titled `it` blocks: Mocha runs both and `--grep` cannot tell them apart.
// test/user.test.js
const assert = require('node:assert');
const { createUser } = require('../src/user');
describe('User', () => {
describe('createUser', () => {
it('returns an object with an id', () => {
const user = createUser({ email: 'a@b.c' });
assert.ok(user.id);
});
it('sets default role to "member"', () => {
const user = createUser({ email: 'a@b.c' });
assert.strictEqual(user.role, 'member');
});
});
});
Key Points
- describe = suite, it = test
- Nesting is unlimited
- Strings are for humans, not selectors
Q3What are Mocha's lifecycle hooks (before, after, beforeEach, afterEach)?
BasicHooks
Answer
Hooks run code at specific points in the test lifecycle. `before` runs once before all tests in the suite, `after` runs once after all tests, `beforeEach` runs before every test, and `afterEach` runs after every test. Use `before`/`after` for expensive one-time setup (database connection, test fixtures). Use `beforeEach`/`afterEach` for per-test isolation (resetting state, clearing mocks).
Hooks inherit down nested suites, a `beforeEach` in the outer `describe` also runs for tests in nested describes. Order matters and is worth stating precisely: multiple hooks of the same type run in declaration order, outer `beforeEach` hooks run before inner ones, and `afterEach` hooks unwind inner-first. Hooks carry their own timeout, separate from the test, so a `before` that opens a Docker container will fail with 'Timeout of 2000ms exceeded' unless you raise it.
Failure semantics differ from tests: when a `beforeEach` throws, Mocha reports `"before each" hook for "<first test name>"` and aborts the remaining tests in that suite rather than failing each one, which is why one broken fixture shows up as a single failure and dozens of missing tests. Name your hooks (`beforeEach('seed users', async () => {...})`) so that message identifies the culprit instead of dumping an anonymous function. Inside `afterEach` you can read `this.currentTest.state` and `this.currentTest.title` to dump logs or screenshots only when a test failed, which is the standard way to make CI failures debuggable without drowning passing runs in output.
describe('UserRepo', () => {
let db;
before(async () => {
db = await connectTestDb();
});
after(async () => {
await db.close();
});
beforeEach(async () => {
await db.query('DELETE FROM users');
});
it('inserts a user', async () => {
await db.insert({ email: 'a@b.c' });
const rows = await db.query('SELECT * FROM users');
assert.strictEqual(rows.length, 1);
});
});
Q4How do you test asynchronous code in Mocha?
BasicAsync
Answer
Mocha supports three async styles: (1) **callback-based**, accept a `done` parameter and call `done()` when complete, or `done(err)` on failure; (2) **promise-based**, return a promise from the test, Mocha awaits it; (3) **async/await**, declare the test as `async`, use `await` normally. Async/await is the modern standard in 2026. Never mix `done` and a promise return, Mocha will throw 'Resolution method overspecified' because it can't tell which to trust.
The failure mode that costs the most debugging time is forgetting the `return` in style (2). Without it the test body finishes synchronously, Mocha marks the test passed in microseconds, and the assertion that would have failed surfaces later as an unhandled rejection attributed to whichever test happened to be running at the time. Enable `--async-only` to make Mocha fail any test that neither returns a promise nor takes `done`, and turn on the TypeScript ESLint rule `no-floating-promises`, which catches the same class of bug at lint time.
Two more sharp edges: calling `done()` twice produces 'done() called multiple times', usually because an event fired again or a callback ran on both success and error paths; and passing a non-Error value to `done` is reported as 'done() invoked with non-Error'. For event-driven code prefer `await once(emitter, 'ready')` from `node:events`, which converts the emitter into a promise and lets you stay on async/await instead of reaching for `done` at all.
// 1. async/await (preferred in 2026)
it('fetches user', async () => {
const user = await fetchUser(42);
assert.strictEqual(user.id, 42);
});
// 2. Promise return
it('fetches user', () => {
return fetchUser(42).then(user => assert.strictEqual(user.id, 42));
});
// 3. done callback (legacy)
it('fetches user', (done) => {
fetchUser(42, (err, user) => {
if (err) return done(err);
assert.strictEqual(user.id, 42);
done();
});
});
Q5How do you install and run Mocha?
BasicSetup
Answer
Install as a dev dependency: `npm install --save-dev mocha`. Add `"test": "mocha"` to your `package.json` scripts. By default Mocha looks for files matching `./test/*.{js,mjs,cjs}`, meaning anything inside the `test/` directory at the root of your project.
You don't have to use `npx` if you've added a script; `npm test` runs through the local `node_modules/.bin/mocha`. Customize discovery via CLI flags or a `.mocharc.json` config file. To run a single test file: `npx mocha test/user.test.js`.
To filter by name: `npx mocha --grep 'createUser'`. For watch mode during development: `npx mocha --watch`. Mocha v11 in 2026 requires Node 18+ and works seamlessly in both CommonJS and ESM projects.
A few operational details come up constantly. Arguments after `--` are forwarded through npm, so `npm test -- --grep auth --bail` works without editing the script. Globs must be quoted (`'test/**/*.test.js'`), because zsh and bash expand an unquoted glob themselves and Mocha then only sees the first level of matches, which is the usual reason a nested test directory appears to be skipped.
Mocha resolves `.mocharc.*` relative to the current working directory, not the file being run, so invoking it from a monorepo root against a package's tests silently picks up the wrong config. Avoid installing Mocha globally: a global v10 shadowing a project's v11 produces confusing flag errors. Verify what you are actually running with `npx mocha --version` and dump the resolved settings with `npx mocha --config .mocharc.json --dry-run`, which lists the tests Mocha would execute without executing them.
// package.json
{
"scripts": {
"test": "mocha",
"test:watch": "mocha --watch",
"test:coverage": "c8 mocha"
},
"devDependencies": {
"mocha": "^11.0.0",
"chai": "^5.1.0",
"c8": "^10.1.0"
}
}
Q6What is an assertion library and why does Mocha need one?
BasicAssertions
Answer
Mocha is just a test runner, it does not include assertion functions like `assertEqual` or `expect`. You bring your own. This is the most visible expression of Mocha's unopinionated philosophy.
Two common choices in 2026: (1) **Node's built-in `assert`** (or the stricter `node:assert/strict`), zero dependencies, simple API, ships with Node, fast. (2) **Chai**, three styles (`assert`, `expect`, `should`), expressive chains like `expect(user).to.have.property('email').that.is.a('string')`. Chai is more popular in legacy Node.js codebases; the built-in `assert` is gaining ground in 2026 because it's dependency-free and Node's `assert/strict` covers most needs. Whichever you pick, an assertion failure throws an `AssertionError`, which Mocha catches and reports as a test failure with a diff.
Chai's plugins (`chai-as-promised` for async, `sinon-chai` for stub assertions) add ergonomic helpers that pure node:assert doesn't have, that's the main reason older codebases stay on Chai. Two details a senior interviewer probes. First, legacy `assert.equal` uses loose `==`, so `assert.equal(1, '1')` passes; `node:assert/strict` (or `assert.strictEqual`) is what you want, and mixing the two in one codebase hides real type bugs.
Second, Mocha's diff output is not magic: it renders a coloured expected/actual diff only when the thrown error carries `actual` and `expected` properties and `showDiff` is truthy. Chai and `node:assert` both set them, but a hand-rolled `throw new Error('mismatch')` gives you no diff at all, which is why custom domain assertions should build on `assert.deepStrictEqual` or set those properties themselves. Chai 5 is ESM-only, so CommonJS suites either stay on `chai@4` or load it with a dynamic `await import('chai')` inside a `before` hook. If you want Jest-style matchers without leaving Mocha, `expect` from Vitest or the standalone `expect` package drops in as a plain module and Mocha reports its failures normally.
// Using node:assert (zero deps)
const assert = require('node:assert/strict');
assert.equal(2 + 2, 4);
assert.deepEqual([1, 2], [1, 2]);
// Using Chai's expect
const { expect } = require('chai');
expect(user).to.have.property('email');
expect([1, 2, 3]).to.include(2);
Q7How do you skip or focus tests in Mocha?
BasicTest Control
Answer
Use `.skip` to skip a suite or test, and `.only` to run only the marked test(s) and ignore everything else. These exist on both `describe` and `it`. `.only` is invaluable for fast iteration when debugging, run just the failing test instead of waiting for the whole suite. You can also conditionally skip at runtime by calling `this.skip()` inside a test or hook, useful for environment-specific tests (e.g. skip when `process.env.CI` is missing, or skip integration tests when `DATABASE_URL` isn't set). `pending tests` (no callback) show up as 'pending' in the report, handy for placeholder test ideas you want to remember to write.
Use sparingly; many pending tests in a codebase is a smell that means TODO comments have turned into permanent residents. The distinction interviewers push on is `this.skip()` versus an early `return`. An early `return` reports the test as passing, which is actively misleading: the suite looks green while nothing was verified. `this.skip()` marks it pending, so the report tells the truth.
Where you call it also matters: `this.skip()` inside a `before` hook marks every test in that suite pending, while inside `beforeEach` or the test itself it skips only that one test. Implementation detail with teeth: `this.skip()` works by throwing a special Pending object, so a `try/catch` wrapped around it swallows the skip and the test continues running. Add `--forbid-only` and `--forbid-pending` to your CI config so a stray `it.only` or a permanently pending test fails the build with exit code 1 instead of quietly shrinking coverage. Without `--forbid-only`, a committed `.only` makes CI run one test and report a green build, which is the single most dangerous accident in this list.
describe('User', () => {
it.only('runs only this test', () => { /* ... */ });
it.skip('skipped for now', () => { /* ... */ });
it('runs the integration test', function() {
if (!process.env.DATABASE_URL) return this.skip();
// ...
});
it('TODO: implement reset logic'); // pending
});
Q8What's the difference between arrow functions and regular functions in Mocha tests?
BasicGotchas
Answer
Mocha sets `this` to a test context object that exposes APIs like `this.timeout(ms)`, `this.slow(ms)`, `this.retries(n)`, and `this.skip()`. Arrow functions don't have their own `this`, they inherit from the enclosing scope, so you lose access to these helpers. Use regular `function()` syntax in tests and hooks where you need `this`.
Conversely, when you don't need any of these context APIs, arrow functions are fine and more concise. This is one of the most common Mocha pitfalls, especially for developers coming from Jest (which uses `jest.setTimeout()` instead of `this.timeout()` and so works fine with arrows). Some teams enforce regular functions in tests via an ESLint rule (`prefer-arrow-callback` disabled in `test/**`), so the codebase is consistent and `this.timeout()` always works.
Know the exact symptom, because it differs by module system: in a CommonJS test file, top-level `this` is `module.exports`, so an arrow-function test calling `this.timeout(5000)` throws `TypeError: this.timeout is not a function`; in an ESM file top-level `this` is `undefined`, so you get `Cannot read properties of undefined (reading 'timeout')`. Neither message mentions arrow functions, which is why the bug survives so long. The same applies to `describe`: an arrow callback there loses suite-level `this.timeout()`, `this.retries()` and `this.bail()`.
Async tests can still use the context, the signature is just `it('name', async function () { ... })`. If your team prefers arrows everywhere, set timeouts declaratively with `"timeout": 10000` in `.mocharc.json` or per-file via the `--timeout` flag, and skip conditionally with `it.skip` chosen at load time rather than `this.skip()` at runtime. The follow-up question is usually 'why does Jest not have this problem', and the answer is that Jest exposes configuration through module-level functions like `jest.setTimeout()` rather than through a per-test `this` context.
// Arrow, this.timeout() won't work
it('slow test', () => {
this.timeout(5000); // ❌ undefined
});
// Regular function, works correctly
it('slow test', function() {
this.timeout(5000); // ✅
return slowOperation();
});
Q9How do you set a test timeout in Mocha?
BasicTimeouts
Answer
Mocha's default test timeout is 2000ms. When a test runs longer than that, Mocha kills it and reports failure with a 'Timeout of 2000ms exceeded' message. Adjust at multiple levels: (1) per-test via `this.timeout(ms)`, (2) per-suite via `this.timeout(ms)` inside a `describe`, (3) global via `--timeout=10000` CLI flag or `"timeout": 10000` in `.mocharc.json`.
Use `0` to disable the timeout entirely (e.g. for debugging with breakpoints, otherwise Mocha kills the test as soon as you pause). Don't set huge timeouts to hide flaky tests, fix the underlying race. Hooks have their own timeouts too, a `before` that takes 10 seconds will fail unless you raise the suite timeout.
Common pattern: a per-suite 10-second timeout for integration suites that hit a real database, while unit suites stick to the 2-second default. Read the full message before raising the number: Mocha appends 'For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves', a hint that the real bug is usually a promise that never settles. The clock starts when the test begins, so a slow `beforeEach` eats into nothing but its own hook budget, and hooks report separately as `"before all" hook` timeouts. `--timeout` accepts human-readable strings (`--timeout 5s`, `--timeout 2m`) as well as milliseconds.
Distinguish it from `this.slow(ms)`, which only controls when the spec reporter paints a duration yellow or red (default 75ms) and never fails anything. When stepping through a test with `--inspect-brk`, pass `--timeout 0` explicitly so the debugger pause cannot be mistaken for a hang. In parallel mode, CPU contention from more `--jobs` than cores can push borderline tests over the limit.
describe('Integration', function() {
this.timeout(10000); // 10s for all tests in this suite
it('slow API call', async function() {
this.timeout(30000); // override to 30s just for this one
await reallySlowExternalApi();
});
});
Q10How do you run a single test file or filter tests by name?
BasicCLI
Answer
Pass the file path directly: `npx mocha test/user.test.js`. To filter by test name: `npx mocha --grep 'createUser'` runs only tests whose full title (describe + it concatenated) matches the regex. To invert: `npx mocha --invert --grep 'slow'` runs everything EXCEPT tests matching 'slow'.
Combine with file glob: `npx mocha 'test/**/*.test.js' --grep auth`. The `--bail` flag stops at the first failure, useful for fast feedback when iterating. In CI, this is how you split test runs across parallel jobs, each shard receives a different subset of files.
Most editors also offer 'run focused test' integrations that wrap these flags, so you can run a single test from your cursor position. For debugging, pair `--inspect-brk` with `--no-timeouts` to step through tests in Chrome DevTools or VS Code. Know the neighbours of `--grep`. `--fgrep` matches a fixed string instead of a regex, which is what you want when the test title contains `(`, `[` or `?`, and the two flags are mutually exclusive. `--invert` only works alongside `--grep` or `--fgrep`.
The trap that bites teams in CI: if your pattern matches nothing, Mocha prints '0 passing' and exits 0, so a typo in a shard's grep turns into a permanently green job that runs no tests. Guard against it by checking the reported test count, or by listing matches first with `--dry-run` (Mocha v9+), which resolves files and prints the tests without running them. `--bail` stops at the first failure, `--sort` forces deterministic file order, and `--file setup.js` loads a file before the discovered specs regardless of glob order. Passing explicit file paths overrides the `spec` glob but still applies `require`, so your TypeScript loader keeps working.
# one file
npx mocha test/user.test.js
# regex on the full concatenated title
npx mocha --grep 'createUser'
# fixed string, safe with regex metacharacters
npx mocha --fgrep 'GET /users/:id (404)'
# everything except slow tests, stop on first failure
npx mocha --grep slow --invert --bail
# list what would run, without running it
npx mocha --dry-run --grep auth
# forward flags through npm scripts
npm test -- --grep auth --reporter dot
Q11What is a test reporter in Mocha?
BasicReporters
Answer
A reporter formats test results, same test run, different output. Mocha ships with several built-in reporters: `spec` (default, hierarchical text with check/cross marks, friendly for local dev), `dot` (compact, one dot per test, good for CI where you don't want walls of output), `nyan` (the famous nyan cat, surprisingly motivating during a long run), `tap` (TAP protocol for CI integration), `json` (machine-readable for custom tooling), `xunit` (JUnit XML for Jenkins/CircleCI). Set with `--reporter=spec` or in `.mocharc.json`.
For CI artifacts, use `mocha-junit-reporter` to generate XML that GitHub Actions, GitLab, and CircleCI all consume natively, this is how you get the 'Tests' tab on PRs showing exactly which test failed without needing to scroll through raw logs. You can also write custom reporters by extending Mocha's `Base` reporter, useful for piping results to Slack or an internal dashboard. Reporter options are passed with `--reporter-option key=value` (repeatable); the older comma-separated `--reporter-options` form still parses but is deprecated. `mocha-junit-reporter` takes `mochaFile=./reports/junit-[hash].xml`, and the `[hash]` placeholder is what stops parallel shards from overwriting each other's output.
A detail worth knowing in production: the `json` and `tap` reporters write to stdout, so a stray `console.log` inside application code corrupts the payload and the CI parser fails with a JSON syntax error rather than a test failure, which sends people hunting in the wrong place. Route reporter output to a file, or silence app logging in the test env. Pass `--no-color` (or let Mocha detect a non-TTY) so ANSI escape codes do not end up embedded in stored logs. A custom reporter subclasses `Mocha.reporters.Base` and subscribes to events from `Mocha.Runner.constants`; in parallel mode it must tolerate out-of-order events, since results arrive as workers finish.
// .mocharc.json
{
"reporter": "spec",
"timeout": 5000
}
// CI override: npx mocha --reporter mocha-junit-reporter --reporter-options mochaFile=./junit.xml
Q12How do you organize Mocha tests in a project?
BasicOrganization
Answer
Two common conventions in 2026: (1) **Mirror source tree**, `src/services/user.js` → `test/services/user.test.js`. Clean separation, easy to gitignore test files from build output, makes it trivial to publish a library without test files. (2) **Colocated**, `src/services/user.js` next to `src/services/user.test.js`. Easier to find tests when reading source, but you need a glob to exclude tests from your production bundle and your `tsconfig.json`.
Mocha's default config looks for `./test/`, so (1) is the lowest-friction. For a TypeScript Node.js project at a company like Postman, you'd typically see option (1) with a `.mocharc.cjs` that loads `ts-node/register` or `tsx`, plus a separate `test/integration/` directory with its own config and longer timeouts. Whichever you pick, be consistent, mixed conventions confuse new joiners and make CI sharding harder.
Three practical rules keep the layout from rotting. Keep helpers and fixtures outside the `spec` glob: a `test/helpers/factory.ts` that matches `test/**/*.ts` gets loaded as a test file, contributes zero `it` blocks, and quietly adds to startup time while making `--dry-run` output noisy. Name test files with one suffix, `.test.ts` or `.spec.ts`, and match it exactly in `spec` plus `extension`, because Mocha's `extension` option controls directory discovery while `spec` controls glob matching, and disagreement between them is the usual cause of 'my new test never runs'.
In a monorepo, give every package its own `.mocharc.cjs` and run Mocha with that package as the working directory, since config resolution is cwd-based. For a TypeScript service you typically end up with `test/unit` on the 2-second default timeout, `test/integration` on 10 to 30 seconds with a Testcontainers or docker-compose backing store, and separate npm scripts so a developer can run the fast layer in under ten seconds.
Q13Mocha prints 'Error: No test files found'. How does test file discovery actually work?
BasicCLI
Answer
Mocha's default `spec` is `./test/*.{js,cjs,mjs}`, a single level deep, relative to the current working directory. Four things break discovery, and the error message names none of them. (1) **Depth**: files in `test/unit/user.test.js` are invisible until you add `--recursive` or widen the glob to `test/**/*.test.js`. (2) **Extension**: for TypeScript you need both `"extension": ["ts"]` (which controls what Mocha considers a test file when walking a directory) and a matching `spec` glob. Setting only one is the usual cause of a new test that never runs and never errors. (3) **Shell expansion**: an unquoted `test/**/*.test.js` is expanded by bash or zsh before Mocha sees it, and default shell globbing does not recurse, so Mocha receives one level of matches.
Always quote the glob. (4) **Working directory**: `.mocharc.*` and all relative globs resolve from the cwd, so running the root `npm test` in a monorepo against a package's tests finds nothing. Debug it with `--dry-run`, which resolves and lists the tests without running them, or `--reporter=dot --dry-run` for a quick count. Also remember Mocha exits 0 when zero tests match, so this failure can hide as a green CI job rather than a red one; assert a minimum test count in CI if that risk is real for you.
// .mocharc.json, recursive TypeScript discovery
{
"spec": ["test/**/*.test.ts"],
"extension": ["ts"],
"recursive": true,
"ignore": ["test/helpers/**", "test/fixtures/**"],
"require": ["@swc-node/register"]
}
# quote globs so the shell does not expand them first
npx mocha 'test/**/*.test.ts'
# see what Mocha resolved, without running anything
npx mocha --dry-run
Q14What are Mocha's interfaces (bdd, tdd, exports, qunit) and when would you change `--ui`?
BasicFundamentals
Answer
An interface decides which global functions Mocha injects. `bdd` is the default and gives you `describe`, `it`, `before`, `after`, `beforeEach`, `afterEach`, plus the `context`/`specify` aliases. `tdd` gives you `suite`, `test`, `suiteSetup`, `suiteTeardown`, `setup`, `teardown`, which some teams migrating from older xUnit-style suites prefer. `exports` uses no globals at all: you export a plain object whose keys are suite names and whose function values are tests, with `before`/`after` as reserved keys. `qunit` gives a flat `suite`/`test` style with no nesting. Switch with `--ui tdd` on the CLI or `"ui": "tdd"` in `.mocharc.json`. In practice almost everyone stays on `bdd`, and the honest interview answer is that `--ui` matters in exactly two situations: porting an old suite without rewriting every block, and writing tests in an environment where injected globals are a problem, where the `exports` interface keeps the module surface explicit and works cleanly with ESM.
The gotcha is that the interface is global to the run, so you cannot mix `describe` and `suite` across files in one Mocha invocation; a partially migrated codebase needs two configs and two npm scripts. Type definitions follow the same split, `@types/mocha` declares the `bdd` globals by default, which is why TypeScript reports 'Cannot find name suite' the moment you switch `--ui` without updating `tsconfig.json` types.
// --ui tdd
suite('Array', function () {
setup(function () { this.arr = [1, 2, 3]; });
test('#indexOf() returns -1 when absent', function () {
assert.strictEqual(this.arr.indexOf(9), -1);
});
});
// --ui exports (no globals at all)
module.exports = {
'Array': {
before() { /* suite setup */ },
'#indexOf() returns -1 when absent'() {
assert.strictEqual([1, 2, 3].indexOf(9), -1);
},
},
};
Q15How do you share state between hooks and tests using Mocha's `this` context?
BasicHooks
Answer
Every suite gets a `Context` object, and Mocha binds it as `this` inside `describe`, `it`, and all four hooks. Assigning `this.user = await createUser()` in a `beforeEach` makes it readable as `this.user` in any test in that suite, and nested suites inherit through the prototype chain, so an outer hook's value is visible to inner tests. That is the mechanism, but the interesting part is when to use it versus a plain closure variable.
A `let user;` declared in the `describe` callback does the same job, works with arrow functions, and is typed correctly in TypeScript without extra work, whereas `this` is typed as `Mocha.Context` and needs a `declare module 'mocha'` interface augmentation before `this.user` compiles. Most teams standardise on closure variables for fixtures and keep `this` for the runner APIs. The context does carry things a closure cannot: inside `beforeEach` and `afterEach`, `this.currentTest` gives you the test about to run or just finished, with `.title`, `.fullTitle()`, `.state` ('passed', 'failed' or undefined when skipped), `.duration` and `.currentRetry()`.
Inside the test body the same object is `this.test`. That is how you attach a screenshot or a request log to exactly the failing case. Two traps: values set in `before` persist for the whole suite, so a mutated object leaks between tests and creates order dependence, and an arrow function has no `this` binding at all, so `this.user` is `undefined` rather than an error.
describe('InvoiceService', function () {
// closure fixture: simplest, works with arrow tests too
let service;
beforeEach(function () {
service = new InvoiceService({ gst: 0.18 });
this.invoice = service.create({ amountPaise: 100000 }); // context fixture
});
it('applies 18% GST', function () {
assert.strictEqual(this.invoice.totalPaise, 118000);
});
afterEach(function () {
if (this.currentTest.state === 'failed') {
console.error('failed:', this.currentTest.fullTitle(),
'after', this.currentTest.duration, 'ms',
'retry', this.currentTest.currentRetry());
}
});
});
Key Points
- `this` is a per-suite Context, inherited by nested suites
- `this.currentTest` in hooks, `this.test` inside a test
- Arrow functions silently give `undefined`, not an error
- Closure variables are usually the better fixture channel in TypeScript
Q16How do you configure Mocha using .mocharc.json?
IntermediateConfiguration
Answer
Mocha looks for a config file in this order: `.mocharc.cjs`, `.mocharc.js`, `.mocharc.yaml`, `.mocharc.yml`, `.mocharc.json`, `.mocharc.jsonc`, then a `mocha` key in `package.json`. JSON is enough for most projects and has the advantage of being easily parseable by tooling. Common options: `spec` (glob for test files), `require` (modules to load before tests, e.g. `ts-node/register`), `timeout`, `recursive` (whether to descend into subdirectories), `extension` (file extensions to discover), `reporter`, `ignore`.
The CJS form is needed if you want dynamic config (e.g. compute paths from env vars, or pick different settings for CI vs local). Many projects keep a base `.mocharc.json` plus a `.mocharc.ci.cjs` that extends it with stricter settings (`forbid-only`, `check-leaks`, `reporter: 'mocha-junit-reporter'`), invoked in CI with `--config .mocharc.ci.cjs`. This separation prevents devs from accidentally getting CI-only flags during local iteration.
Precedence is the part people get wrong: CLI flags beat the config file, the config file beats the `mocha` key in `package.json`, and Mocha stops at the first config file it finds rather than merging them. There is no layering, so a root `.mocharc.js` silently wins over the `.mocharc.json` you just edited in a subdirectory. Point at an explicit file with `--config path` and disable lookup entirely with `--no-config` and `--no-package` when you need a reproducible run.
For composition, have `.mocharc.ci.cjs` `require('./.mocharc.json')` and spread it, which is explicit and debuggable. Option names accept both kebab-case and camelCase (`forbid-only` and `forbidOnly` both work), which matters when you translate flags into JSON. Two keys worth knowing: `node-option` passes V8 and Node flags such as `max-old-space-size=4096` or `experimental-vm-modules` through to the worker processes, and `loader` is the ESM counterpart of `require`, since an ESM loader cannot be installed by a CommonJS `require` hook.
// .mocharc.json
{
"spec": ["test/**/*.test.ts"],
"require": ["ts-node/register", "test/setup.ts"],
"extension": ["ts"],
"recursive": true,
"timeout": 10000,
"reporter": "spec",
"forbid-only": true,
"check-leaks": true
}
Key Points
- `spec` is a glob/array of globs
- `require` loads modules before any test (TS setup, env loading)
- `forbid-only` makes CI fail if anyone left `.only` in
Q17How do you use Mocha with TypeScript in 2026?
IntermediateTypeScript
Answer
Three approaches: (1) **ts-node/register**, classic, slow on large codebases but solid. (2) **swc/register** or **@swc-node/register**, 10-20× faster than ts-node, drop-in replacement, becoming the default in 2026. (3) **tsx**, newer, Node-native, very fast. Mocha v11 also has improved native ESM support; if your tests are pure ESM you can use Node's `--experimental-strip-types` (Node 22.6+) and skip the loader entirely. For a Postman-scale codebase, swc-register is the practical choice, startup time matters when you run tests 50 times a day.
The trade-off nobody mentions until it bites: swc and tsx strip types without checking them, so a test that would not compile still runs. Keep `tsc --noEmit` as a separate CI step, otherwise you have silently traded type safety for speed. Three more setup details.
Add `"types": ["mocha", "node"]` to `tsconfig.json` or install `@types/mocha`, or TypeScript reports 'Cannot find name describe'. Enable source maps (`--enable-source-maps` via `node-option`, or `require: ['source-map-support/register']`) so a failing assertion points at `user.test.ts:42` instead of a line in generated JavaScript, which is the difference between a 30-second and a 30-minute debug. If you use `paths` aliases, add `tsconfig-paths/register` to `require`, because neither swc nor tsx resolves tsconfig path aliases on its own.
Node's `--experimental-strip-types` is genuinely useful for straightforward code but refuses TypeScript that needs code generation (enums, namespaces, legacy decorators, parameter properties), so most real services stay on a loader. A senior follow-up: how do you know your loader is even active? Run one deliberately broken test and confirm the stack trace shows TypeScript line numbers.
// .mocharc.cjs (using @swc-node/register)
module.exports = {
extension: ['ts', 'tsx'],
spec: ['test/**/*.test.ts'],
require: ['@swc-node/register'],
timeout: 10000,
};
// tsconfig.json, make sure these match
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"esModuleInterop": true
}
}
Q18How does Mocha handle async/await failures vs the `done` callback?
IntermediateAsync
Answer
With `async/await`, any thrown error or rejected promise inside the test bubbles up, Mocha catches it and marks the test failed with the actual stack trace pointing at the line that threw. With `done`, you MUST call `done(err)` explicitly when something goes wrong; an uncaught error inside a `done`-style test is reported but with a less helpful stack and sometimes ends up as a 'Uncaught Exception' from a later test instead of the right one. Common bug: mixing the two.
If a test signature is `async function(done)`, Mocha sees both an async function (returns a promise) and a `done` parameter, it doesn't know which to wait for, so it errors with 'Resolution method overspecified: specify a callback or return a Promise; not both'. Pick one and stick to it. Rule for 2026 codebases: always use async/await unless wrapping an event emitter or a callback-only legacy API.
If you do use `done`, wrap the inside in a try/catch so synchronous assertion failures propagate to `done(err)` instead of bubbling as an unhandled exception. The production consequence is misattribution. When a rejection escapes after Mocha has already recorded a result, the error lands on whatever test is running next, so the spec output blames an innocent test and the real culprit passes.
On modern Node an unhandled rejection is fatal by default (`--unhandled-rejections=throw`), which turns this into a mid-run process crash and, under `--parallel`, the loss of every remaining test in that worker. Two flags help while debugging: `--allow-uncaught` stops Mocha from catching the error so it surfaces with a clean stack under `--inspect-brk`, and `--async-only` rejects any test that neither returns a promise nor accepts `done`.
// ❌ Will fail with 'Resolution method overspecified'
it('bad', async (done) => {
await fetchUser();
done();
});
// ✅ Just async
it('good', async () => {
await fetchUser();
});
// ✅ done is fine for event-based async (no promise to await)
it('emits event', (done) => {
emitter.once('ready', () => done());
emitter.start();
});
Q19How do you test exceptions and rejected promises in Mocha?
IntermediateAssertions
Answer
For synchronous throws: pass a function to `assert.throws()` or Chai's `expect(fn).to.throw()`. For async rejections: use `await assert.rejects()` (Node assert) or Chai's `chai-as-promised` plugin. The common trap is forgetting to await, `assert.rejects()` returns a promise, so an un-awaited call lets the test 'pass' even when the assertion would have failed.
ESLint's `require-await` and `no-floating-promises` (TypeScript) catch this. Avoid the anti-pattern of wrapping in try/catch with a manual `assert.fail()`, it produces less helpful error messages and is one extra line you can forget. When matching error properties, pass an object (`{ name, message, code }`) rather than just a regex, that way you assert both the error type AND the message, catching cases where the right message is thrown from the wrong error class.
The subtler mistake is argument shape. `assert.throws()` and Chai's `.to.throw()` need a function, so `expect(parseAge('abc')).to.throw()` never tests anything: the call runs first, throws before `expect` is reached, and the test fails with the raw error instead of passing. Wrap it in a thunk. `assert.rejects()` is the opposite, it accepts either a promise or a function returning one, so passing `fetchUser(-1)` directly is correct there. With `chai-as-promised` you must call `chai.use(chaiAsPromised)` once in a setup file, and every `rejectedWith` expression returns a promise that has to be awaited or returned.
For Node built-ins, assert on `err.code` (`ERR_INVALID_ARG_TYPE`, `ECONNREFUSED`) rather than the message, since messages change between Node releases and codes do not. And prefer `assert.rejects(fn, { name: 'NotFoundError' })` over `instanceof` checks when errors cross a module boundary that might have loaded two copies of the same package, a real hazard in monorepos with hoisted dependencies.
const assert = require('node:assert/strict');
// Sync throw
it('throws on invalid input', () => {
assert.throws(() => parseAge('abc'), /not a number/);
});
// Async rejection
it('rejects on missing user', async () => {
await assert.rejects(
fetchUser(-1),
{ name: 'NotFoundError', message: /user/ }
);
});
// Chai-as-promised
await expect(fetchUser(-1)).to.be.rejectedWith(NotFoundError);
Q20How do you mock dependencies in Mocha tests using Sinon?
IntermediateMocking
Answer
Mocha doesn't ship a mocking library, Sinon.js is the standard pair. Sinon provides three primitives: (1) **spies** wrap a function to record calls without changing behavior, useful when you want to verify a callback fired but don't want to change what it does. (2) **stubs** replace a function with a fake implementation you control, return canned values, throw errors, resolve promises. (3) **mocks** are stubs with pre-set expectations that automatically verify on `mock.verify()`. Use `sinon.createSandbox()` per test and restore in `afterEach` to prevent test pollution, without this, a stub leaked from test A breaks test B in confusing ways.
For ESM modules, you'll often need `esmock` or `proxyquire` since Sinon can't stub module imports directly, the way ESM bindings work makes monkey-patching impossible. Prefer constructor or dependency injection over import stubbing wherever possible; tests stay simpler and the code itself becomes more modular as a side effect. The failure that wastes an afternoon is destructuring. `const { send } = require('../mailer')` captures the original function reference at import time, so `sandbox.stub(mailer, 'send')` replaces the property on the module object while your code still calls the captured copy and the stub records nothing.
Call through the namespace (`mailer.send(...)`) or inject the dependency. Know the reset vocabulary too: `resetHistory()` clears recorded calls but keeps behavior, `resetBehavior()` does the opposite, `reset()` does both, and only `restore()` puts the original function back. `sinon.assert.calledWith` compares arguments with deep equality, so for partial matching use `sinon.match({ userId: 42 })` or `sinon.match.string` instead of reconstructing whole objects in the assertion. On frozen or ESM namespace objects, stubbing throws `TypeError: Cannot redefine property`, the signal to switch to `esmock` or injection.
const sinon = require('sinon');
const mailer = require('../src/mailer');
const { signup } = require('../src/auth');
describe('signup', () => {
let sandbox;
beforeEach(() => { sandbox = sinon.createSandbox(); });
afterEach(() => { sandbox.restore(); });
it('sends a welcome email', async () => {
const sendStub = sandbox.stub(mailer, 'send').resolves();
await signup({ email: 'a@b.c' });
sinon.assert.calledOnceWithExactly(sendStub, 'a@b.c', 'welcome');
});
});
Q21What's the difference between root-level hooks and nested hooks?
IntermediateHooks
Answer
Hooks declared at the top of a file (outside any `describe`) are 'root hooks' and run for every test file that Mocha loads. Useful for global setup like initializing a logger or connecting to a test database. But there's a subtlety: in Mocha v8+, root hooks declared in test files no longer automatically apply across files unless you use the **root hook plugin**, export a `mochaHooks` object from a setup file required via `--require`.
Nested hooks (inside `describe`) only apply within that suite and any child suites. This change tripped up many teams upgrading from Mocha v7. The reason for the change is worth stating, because interviewers ask: under `--parallel` each worker process loads only the files assigned to it, so a hook defined in `test/a.test.js` simply does not exist in the worker running `test/b.test.js`.
The root hook plugin solves this by putting hooks in a module that every worker loads through `require` or `loader`, which is why the plugin is mandatory rather than stylistic once you go parallel. Note the naming: the plugin object uses `beforeAll`, `afterAll`, `beforeEach`, `afterEach`, not `before`/`after`, and each key also accepts an array of functions that run in order, so several setup modules can each contribute hooks. `this` inside a root hook is the Mocha context shared with tests, so `this.db` set in `beforeAll` is readable as `this.db` inside any `it` that uses a `function()` body. Root hooks still run once per worker process, not once per run, so anything genuinely global (running migrations, starting a container) needs either `--jobs=1`, an external orchestration step, or a lock file to make the work idempotent across workers.
// test/setup.js, required via .mocharc 'require'
exports.mochaHooks = {
async beforeAll() { global.db = await connectTestDb(); },
async afterAll() { await global.db.close(); },
async beforeEach() { await global.db.query('BEGIN'); },
async afterEach() { await global.db.query('ROLLBACK'); },
};
// .mocharc.json
{ "require": ["test/setup.js"] }
Q22How do you measure code coverage with Mocha?
IntermediateCoverage
Answer
Mocha doesn't have built-in coverage, like assertions and mocks, it's something you compose in. Two tools dominate in 2026: (1) **c8**, uses Node.js's built-in V8 coverage, zero-config for ESM, much faster than nyc because there's no source instrumentation step. The recommended default for new projects. (2) **nyc** (Istanbul), older but battle-tested, instruments source before execution, better for CommonJS-only projects and detailed HTML reports with per-statement annotations.
Wrap the mocha command: `c8 mocha` or `nyc mocha`. Output HTML, text-summary, and lcov reports for CI (Codecov, Coveralls). Realistic targets: 70-80% line coverage for application code, 90%+ for pure logic libraries.
Don't chase 100%, the last 5% is usually error-handling paths or polyfills that aren't worth the test maintenance burden. Use `check-coverage` with `lines`/`branches`/`functions` thresholds to fail the build when coverage drops, but be careful with absolute thresholds in monorepos where one heavy file can swing the average. Two mechanics explain most coverage confusion.
First, `"all": true` is what makes files with zero tests appear in the report at 0%; without it, coverage only counts files that were loaded, so deleting the only test for a module makes coverage go up. That is the single most common way a team fools itself. Second, c8 reads V8 coverage of code that actually executes, so with a TypeScript loader it maps back through source maps; numbers pointing at compiled output mean missing source maps, not wrong coverage.
Under `--parallel`, c8 merges one report per worker from its temporary directory, so run c8 once around the whole Mocha invocation. Ignore generated files and migrations explicitly, and prefer branch coverage over line coverage as your gate, since line coverage happily rewards a test that calls a function and asserts nothing.
// package.json
{
"scripts": {
"test": "mocha",
"coverage": "c8 --reporter=text --reporter=lcov mocha"
}
}
// .c8rc.json
{
"all": true,
"include": ["src/**"],
"exclude": ["src/**/*.test.ts", "test/**"],
"check-coverage": true,
"lines": 80,
"branches": 75
}
Q23How do you retry flaky tests in Mocha?
IntermediateRetries
Answer
Use `this.retries(n)` to re-run a test up to `n` times on failure. Set at suite or test level. Mocha runs the test fresh each retry, including `beforeEach`/`afterEach`.
Retries are a code smell: they paper over genuine bugs (race conditions, undeclared dependencies between tests). Use them sparingly, only for integration tests against external systems (third-party APIs, network) where flakiness is genuinely outside your control. Don't enable retries globally, that hides real regressions.
Know exactly what a retry does and does not replay: Mocha reruns the test body plus `beforeEach` and `afterEach`, but not `before` or `after`. So a suite whose fixture is created once in `before` retries against already-mutated state, and the second attempt can fail for a completely different reason than the first, which is how a retry turns one confusing failure into two. Retries are also invisible in most reporters by default, the run just reports a pass, so a suite can degrade for months while the dashboard stays green.
If you use them, emit a signal: in `afterEach`, check `this.currentTest.currentRetry() > 0` and log or push a metric so retried tests show up somewhere a human looks. `--retries N` sets it globally from the CLI, and it composes badly with `--bail`, since bail stops on the final failure only after retries are exhausted. Retries also do not help the most common flake source, which is order dependence between tests; rerunning the same polluted state gives the same result. Treat a retry as a dated exception with a ticket number in a comment, not as configuration.
describe('External API integration', function() {
this.retries(2); // up to 3 total attempts
it('fetches user from upstream', async function() {
const user = await externalApi.getUser(42);
assert.strictEqual(user.id, 42);
});
});
Q24How do you run Mocha tests in parallel?
IntermediatePerformance
Answer
Mocha v8+ supports parallel execution via `--parallel`. It spawns one worker process per CPU core (configurable with `--jobs=N`) and distributes test files across them. Each file runs in isolation in its own process, which means a memory leak or process.exit() in one file can't affect another.
Important caveats: (1) Tests in the same file still run sequentially, parallelism is at the file level, not the test level. So a 10-second test file is still 10 seconds even with parallel enabled. (2) Root hooks declared as plain `before`/`beforeEach` won't work, you must use the root hook plugin pattern with `mochaHooks` export, because the runner needs to apply those hooks in each worker process. (3) Shared resources (a single test database) become contention points; either use per-worker isolation (e.g. one schema per worker indexed by `process.env.MOCHA_WORKER_ID`) or skip `--parallel` for integration tests. (4) Reporter output is reordered, use a parallel-aware reporter or the default `spec` reporter, which Mocha buffers and prints in original file order. Speedup is usually 3-5× on an 8-core machine for unit tests.
Two more limits to name in an interview: `--parallel` is incompatible with `--delay` and with `--file`, and reporters that assume a single ordered stream do not work. Because each worker is a separate process, module-scope caches are rebuilt once per worker, so a heavy `before` costs more overall even as wall-clock time drops. Parallel mode can even be slower for small suites: worker startup plus loader cost is paid N times, so under roughly 50 files it often loses. Distribution is per file, so one 90-second file sets the floor no matter how many cores you add; splitting it beats adding jobs.
// .mocharc.json
{
"parallel": true,
"jobs": 4, // omit for one job per CPU core
"require": ["test/setup.js"]
}
// CLI: npx mocha --parallel --jobs=4
Q25How do you integrate Mocha with GitHub Actions / CircleCI?
IntermediateCI/CD
Answer
Output JUnit XML using `mocha-junit-reporter`, then have CI consume it for test summaries and flaky-test detection, GitHub Actions has built-in test reporting if you upload the XML as an artifact and use a reporter action. Run coverage in the same job using `c8 --reporter=lcov` and upload to Codecov or Coveralls. Cache `node_modules` (or `~/.npm` plus `package-lock.json` hash) for faster CI, typically cuts a CI run from 5 minutes to 2.
For parallel test splitting in CI, use a shard variable (`MOCHA_SHARD`) combined with `--grep` or list-based file splitting; matrix strategy with 4 shards on a 200-file suite cuts wall-clock time by ~4×. Set `--forbid-only` in CI mocharc so a stray `.only` left in a PR fails the build instead of silently making the suite mostly skip. Get the file naming right when sharding: `mocha-junit-reporter` writes to a single path, so four parallel shards all writing `junit.xml` leave you with one shard's results.
Use the `[hash]` placeholder (`mochaFile=./reports/junit-[hash].xml`) or a per-shard directory, then upload the whole directory as one artifact. Exit codes are what CI reads: Mocha exits with the number of failures, capped at 255, and 0 when everything passes, including when zero tests matched. Pin the Node version in the matrix rather than using `latest`, since a Node minor bump can change error messages your tests assert on.
Two environment differences cause most 'passes locally, fails in CI' reports: timezone (CI runners default to UTC, developer laptops in India are on IST, so any test touching date boundaries needs `TZ=UTC` set explicitly in the workflow) and locale, which changes `toLocaleString` output. Cache `~/.npm` keyed on the `package-lock.json` hash rather than `node_modules`, which avoids stale native builds.
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx c8 --reporter=lcov mocha --reporter=mocha-junit-reporter
env:
MOCHA_FILE: ./junit-${{ matrix.shard }}.xml
- uses: codecov/codecov-action@v4
Q26How do you test an Express.js route handler with Mocha?
IntermediateIntegration Testing
Answer
Two common patterns. (1) **Supertest** for HTTP-level testing, wraps the Express app and makes test requests without binding to a real port. Most idiomatic for API tests, gives you actual request/response cycle including parsing, middleware ordering, and error handlers. (2) **Direct invocation**, call the route handler function with mock `req`/`res` objects (using `node-mocks-http` or hand-rolled). Fastest, but skips middleware so you can miss auth bugs or body-parsing issues.
Pick Supertest when you want confidence that middleware + auth + routing all work together; pick direct invocation for pure controller-logic unit tests where you want to isolate one function. Many Razorpay-style Node.js services have a `tests/api/` directory that's pure Supertest against a real test database, and a `tests/unit/` directory that mocks the database and calls handlers directly, the two layers catch different classes of bugs. The structural requirement is that `app.js` exports the Express app without calling `app.listen()`, with `listen` moved to a separate `server.js`.
If they are in the same file, importing the app in a test binds a real port, and running with `--parallel` gives you `EADDRINUSE` from the second worker. Supertest handles binding itself on an ephemeral port and tears it down per request. Two behaviours to know.
First, Supertest's `.expect(201)` only throws when you `await` or `.end()` the request, so a forgotten `await` makes the assertion vanish. Second, Express 5 (the default since 2024) changed error handling: a rejected promise from an async route handler now reaches the error middleware automatically, where Express 4 required `next(err)` or a wrapper, so tests written against Express 4 behaviour can pass for the wrong reason after an upgrade. Assert on `res.body`, `res.status` and the specific headers your clients depend on rather than snapshotting the whole response, and always include a test that exercises the error middleware, since that path is where production incidents actually originate.
const request = require('supertest');
const { expect } = require('chai');
const app = require('../src/app');
describe('POST /users', () => {
it('creates a user and returns 201', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'a@b.c' })
.expect(201);
expect(res.body).to.have.property('id');
});
it('returns 400 on invalid email', async () => {
await request(app).post('/users').send({ email: 'bad' }).expect(400);
});
});
Q27Where does Mocha fit in the test pyramid?
IntermediateStrategy
Answer
Mocha is a flexible runner that can host any layer of the pyramid: unit (pure functions, controllers with mocked deps), integration (Express routes with a real database via Supertest), and even end-to-end (Mocha + Puppeteer/Playwright, though Playwright's own runner is now more common for E2E). The pyramid in 2026: ~70% unit (Mocha + Chai + Sinon), ~20% integration (Mocha + Supertest + test DB), ~10% E2E (Playwright). Mocha is the unifying runner for the bottom two layers.
Avoid the 'ice cream cone' anti-pattern, heavy E2E and few unit tests, which kills CI speed and produces flaky results. Companies running Mocha at scale (LinkedIn, Coinbase, Postman) lean heavily on unit tests with stubbed dependencies. The Indian fintech playbook is similar, a Razorpay-style service typically has thousands of fast unit tests guarding business logic, hundreds of integration tests verifying SQL queries and external API contracts, and a small set of smoke E2E tests that exercise the critical payment flows against a staging gateway.
Mocha's practical advantage across layers is that the same `describe`/`it` vocabulary, the same reporters and the same JUnit output work at every level, so one CI parser handles everything. Enforce the split with directories and separate configs rather than discipline: `test/unit/.mocharc.json` with a 2000ms timeout and no database access, `test/integration/.mocharc.json` with 30000ms, a Testcontainers or docker-compose dependency, and `--parallel` disabled if the store is shared. Wire them to distinct scripts so `npm run test:unit` stays under ten seconds on a laptop; the moment the fast layer crosses about thirty seconds, people stop running it before pushing and the pyramid stops paying for itself. For contract boundaries with third-party APIs, record fixtures with `nock` rather than calling upstream, and keep one nightly job that hits the real sandbox so you find out when a provider changes a response shape without waiting for a customer to report it.
Q28How do you use Sinon fake timers to test code that depends on `setTimeout` or `Date.now()`?
IntermediateMocking
Answer
Real timers make tests slow and non-deterministic. Sinon's `useFakeTimers()` replaces the global `setTimeout`, `setInterval`, `setImmediate`, `Date`, and `process.nextTick` with controllable fakes. Advance them manually with `clock.tick(ms)` to simulate the passage of time instantly.
Always restore in `afterEach`, leaked fake timers cause cascading failures in later tests. This is essential for testing retry logic, debounce/throttle, expiry windows, and any code that calls `Date.now()` for timestamps. In a payment-gateway codebase, you'd use fake timers to verify that a 30-second OTP expires correctly, without actually waiting 30 seconds in CI.
Sinon delegates to `@sinonjs/fake-timers`, and the option that saves the most debugging is `toFake`. Faking everything freezes time for libraries you did not write: a database driver's keepalive, an HTTP agent's socket timeout and a retry backoff inside the AWS SDK all stop firing, so a test that awaits a real network call hangs until Mocha reports 'Timeout of 2000ms exceeded' and the fake clock gets blamed for a bug it caused indirectly. Pass `{ toFake: ['setTimeout', 'Date'] }` and leave the rest real.
Two more sharp edges. `clock.tick(ms)` fires timers synchronously but does not drain the microtask queue, so a promise chained onto the timer callback has not settled when the next line runs; `await clock.tickAsync(ms)` does both. And Sinon patches the globals only, so a module that did `import { setTimeout } from 'node:timers/promises'` keeps the real implementation and ticking the clock does nothing for it. `clock.runAll()` aborts with 'Aborting after running 1000 timers' when an interval reschedules itself forever, which is a useful accidental detector for runaway retry loops. If your code polls real wall time as well as timers, `{ shouldAdvanceTime: true }` lets the fake clock creep forward on its own.
const sinon = require('sinon');
describe('OTP expiry', () => {
let clock;
beforeEach(() => { clock = sinon.useFakeTimers(new Date('2026-05-12T10:00:00Z')); });
afterEach(() => { clock.restore(); });
it('OTP is valid before 30s', () => {
const otp = createOtp();
clock.tick(29_000);
assert.strictEqual(otp.isValid(), true);
});
it('OTP expires after 30s', () => {
const otp = createOtp();
clock.tick(31_000);
assert.strictEqual(otp.isValid(), false);
});
});
Q29How do you run Mocha against a native ESM package, and what breaks compared to CommonJS?
IntermediateESM
Answer
Set `"type": "module"` in `package.json` or name files `.mjs`, and Mocha loads each test file with dynamic `import()` instead of `require()`. Suite structure is unchanged, but six things behave differently. (1) **Module mocking dies.** ESM namespace objects expose immutable bindings, so `sinon.stub(gateway, 'charge')` throws `TypeError: Cannot redefine property: charge`, and `proxyquire`, which works by patching `require.cache`, has no cache to patch. Your options are `esmock`, a custom loader, or dependency injection, and injection is the one that survives the next runtime change. (2) **`--require` cannot install a loader.** Loader hooks must be registered before module resolution begins, so they go through `node-option` (`loader=esmock`) or Node 20.6+'s `--import` with `module.register()`. Root hook plugins are fine in `require`, because Mocha will `import()` a file that turns out to be ESM. (3) `__dirname` and `__filename` do not exist; use `import.meta.dirname` on Node 20.11+ or `fileURLToPath(import.meta.url)`. (4) Top-level `await` is legal in a test file and runs during the collection phase, before any test executes, which makes it a fine place to read a fixture and a bad place to start a server. (5) `--watch` cannot purge the ESM module registry the way it deletes `require.cache`, so watch mode re-runs against stale modules. (6) Keep config in `.mocharc.json` or `.mocharc.cjs`: a `.mocharc.js` inside a `type: module` package is parsed as ESM and the copied `module.exports = {...}` fails with 'module is not defined'.
// package.json has "type": "module"
// test/payments.test.js
import assert from 'node:assert/strict';
import esmock from 'esmock';
describe('capturePayment', () => {
it('marks the order paid when the gateway captures', async () => {
const { capturePayment } = await esmock('../src/payments.js', {
'../src/gateway.js': { charge: async () => ({ status: 'captured' }) },
});
assert.equal(await capturePayment('ord_1'), 'paid');
});
});
// .mocharc.json, JSON avoids the ESM/CJS ambiguity of .mocharc.js
{
"spec": ["test/**/*.test.js"],
"node-option": ["loader=esmock", "no-warnings"]
}
Q30How do you stub outbound HTTP calls in Mocha tests with nock?
IntermediateMocking
Answer
`nock` intercepts Node's outbound HTTP layer, so the code under test keeps calling the real client (axios, got, `fetch`) and never leaves the process. Define an interceptor with `nock('https://api.razorpay.com').post('/v1/orders').reply(200, { id: 'order_1' })`, then call your service and assert on what it did with the response. The discipline that makes it reliable is three lines of setup.
Call `nock.disableNetConnect()` once in a root hook so any request you forgot to stub fails loudly with 'Nock: Disallowed net connect for ...' instead of quietly hitting production, then `nock.enableNetConnect('127.0.0.1')` so Supertest's local server still works. In `afterEach`, call `nock.cleanAll()`, and before that assert `nock.isDone()` or log `nock.pendingMocks()`. Skipping this is the classic failure: interceptors are consumed one request at a time and are not scoped to a test, so an unused stub from test A satisfies the first request in test B and the assertion passes for entirely the wrong reason.
Details worth knowing: `.times(3)` or `.persist()` for repeated calls, and body matching is exact deep equality unless you pass a predicate function or a `nock.matchHeader` condition, so a service that adds an idempotency key will miss a hard-coded body. Version matters here. Older nock patched only `http.ClientRequest`, so axios was intercepted while global `fetch`, which runs on undici, went straight to the network; nock 14 added undici and `fetch` interception. If your team already uses MSW on the frontend, MSW's Node mode gives you one handler set for both sides.
const nock = require('nock');
const assert = require('node:assert/strict');
const { createOrder } = require('../src/payments');
before(() => {
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1'); // let supertest bind locally
});
afterEach(() => {
const pending = nock.pendingMocks();
nock.cleanAll();
assert.deepEqual(pending, [], 'unused interceptors leak into the next test');
});
after(() => nock.enableNetConnect());
it('retries once on a 502 and then succeeds', async () => {
const scope = nock('https://api.razorpay.com')
.post('/v1/orders', body => body.amount === 50000)
.reply(502)
.post('/v1/orders')
.reply(200, { id: 'order_9', status: 'created' });
const order = await createOrder({ amount: 50000 });
assert.equal(order.id, 'order_9');
assert.ok(scope.isDone());
});
Q31How do you test EventEmitters and Node streams in Mocha without `done` and without hanging the run?
IntermediateAsync
Answer
Convert the event into a promise instead of reaching for `done`. `const [payload] = await once(emitter, 'ready')` from `node:events` resolves with the emitted arguments and, importantly, rejects if the emitter emits `'error'` first, which is exactly the behaviour you want and exactly what a hand-rolled `done` wrapper usually forgets. For a sequence of events, `for await (const [msg] of on(emitter, 'message'))` gives an async iterator you can break out of. Pass `{ signal: AbortSignal.timeout(1000) }` to either one so a missed event fails as an `AbortError` naming the event, rather than as Mocha's generic 'Timeout of 2000ms exceeded' two seconds later.
Ordering is the trap: many emitters fire synchronously, so `emitter.start(); await once(emitter, 'ready')` can miss the event entirely and hang. Create the promise first, then trigger, then await it. For streams, use `node:stream/promises`: `await pipeline(source, transform, destination)` propagates errors and destroys the whole chain on failure, where a manual `.pipe()` chain leaks an open handle and keeps the Mocha process alive after the last test. `await finished(stream)` waits for a stream you did not build with `pipeline`, `Readable.from(['a', 'b'])` builds a fixture source without touching the filesystem, and `await stream.toArray()` on Node 17+ collects output in one line instead of accumulating chunks by hand. Always assert the error path too: a transform that throws should reject the `pipeline` await, and `assert.rejects` with an `err.code` check is the stable way to verify it.
const { once } = require('node:events');
const { Readable } = require('node:stream');
const { pipeline } = require('node:stream/promises');
const assert = require('node:assert/strict');
it('emits ready with the port', async () => {
const server = createServer();
const ready = once(server, 'ready', { signal: AbortSignal.timeout(1000) });
server.start(); // subscribe first, then trigger
const [port] = await ready;
assert.equal(typeof port, 'number');
await server.close(); // or the process never exits
});
it('uppercases every row and fails the pipeline on a bad row', async () => {
const out = [];
await pipeline(
Readable.from(['a', 'b']),
upperCaseTransform(),
async function (src) { for await (const c of src) out.push(c); },
);
assert.deepEqual(out, ['A', 'B']);
await assert.rejects(
pipeline(Readable.from([null]), upperCaseTransform(), async () => {}),
{ code: 'ERR_INVALID_ARG_TYPE' },
);
});
Q32How would you structure a Mocha test suite for a large legacy Node.js codebase?
AdvancedArchitecture
Answer
Picture a 5-year-old payment-gateway codebase (Razorpay-style), 200k LOC, 6000 tests, currently a 25-minute test run. Optimizations in order of payoff: (1) **Enable `--parallel`** with the root-hook plugin pattern for hooks. Typical 3-5× speedup. (2) **Isolate DB state per worker**, one schema or one transaction per test, rolled back after.
No more 'works locally, fails in CI' from test order dependencies. (3) **Split by speed**, `test/unit/`, `test/integration/`, `test/e2e/` directories with separate Mocha configs. Run unit on every commit, integration on PRs, E2E nightly. (4) **Replace `ts-node` with `@swc-node/register`**, 10× faster cold start. (5) **Audit `before` hooks**, most legacy suites have one massive `before` that creates everything; split into per-test fixtures and use parallel-safe factories. (6) **Add `--forbid-only` and `--check-leaks`** to CI to catch slop early. Don't rewrite everything, Mocha's stability means well-organized suites can outlast multiple JS framework cycles.
Sequence matters as much as the list. Measure before you touch anything: `mocha --reporter json > report.json` gives a `duration` for every test, and sorting that with `jq '.tests | sort_by(-.duration)[:20]'` almost always shows a handful of files owning most of the wall clock, which changes the plan from 'add cores' to 'split one file'. Then make the run deterministic with `--sort` and fix the order-dependent failures it exposes, because turning on `--parallel` before that just converts reproducible failures into random ones.
Only after that flip parallel on, and expect two migrations at the same time: root hooks declared inside test files stop applying, and `--file` is rejected outright, so any global bootstrap has to move into a `mochaHooks` export. For untested legacy modules, write characterization tests first: assert the current output, ugly as it is, so the refactor has a tripwire, then introduce a seam by injecting the dependency rather than reaching for `proxyquire`.
# 1. Measure before optimising: per-test durations from the json reporter
npx mocha --reporter json --reporter-option output=report.json
jq '.tests | sort_by(-.duration) | .[0:20] | .[] | "\(.duration)ms \(.fullTitle)"' report.json
# 2. Expose order dependence before enabling parallel
npx mocha --sort --bail
# 3. Split by speed, one config per layer
# package.json
{
"scripts": {
"test:unit": "mocha --config test/unit/.mocharc.json",
"test:int": "mocha --config test/integration/.mocharc.json",
"test:ci": "c8 mocha --config .mocharc.ci.cjs"
}
}
Key Points
- Parallel + root hook plugin = biggest single win
- Per-worker DB isolation eliminates flakiness
- Split unit/integration/E2E directories with separate configs
- swc-node over ts-node for cold-start time
- --forbid-only and --check-leaks in CI
Q33How do you debug a flaky Mocha test that only fails in CI?
AdvancedDebugging
Answer
Step 1: Reproduce locally. Run `npx mocha --bail --retries=0 --reporter=spec --sort` to expose order-dependence, flaky tests often pass alone but fail when run after a sibling. Step 2: Pin random order with `--sort=false --reverse` to surface hidden dependencies.
Step 3: Look for shared mutable state, module-level variables, Sinon stubs not restored, real Date or setTimeout left running. Mocha's `--check-leaks` flag catches globals introduced by tests. Step 4: For race conditions, use `clock = sinon.useFakeTimers()` to remove real-time variance.
Step 5: If it's truly only-in-CI, suspect environment: missing env var, different Node version, different timezone (`TZ=Asia/Kolkata` vs `TZ=UTC`), CPU contention with parallel workers. Step 6: Last resort, add `this.retries(2)` to land while you investigate, but file a ticket, every retried test is a bug deferred. In a Razorpay-style payment service, a flaky test in CI typically blocks the entire team because no one trusts the run; treat them as P1.
What separates a good answer here is the instrumentation you add before you start guessing. In an `afterEach`, check `this.currentTest.state === 'failed'` and dump the request log, the database rows and `this.currentTest.currentRetry()` into the CI artifacts directory, so the next failure arrives with evidence instead of a one-line stack. Quantify the flake rate rather than eyeballing it: `for i in $(seq 1 50); do npx mocha --fgrep 'refunds a captured payment' || echo FAIL; done` tells you whether you are chasing a 2% or a 40% failure.
Reproduce the CI environment rather than the CI machine: `docker run --cpus=2 -e TZ=UTC -e LANG=C node:22` recreates the two conditions that break most 'only in CI' tests, which are CPU starvation making a 1900ms operation cross the 2000ms default timeout, and a UTC runner versus an IST laptop flipping a date boundary. Seed anything random, `faker.seed(1)` or a stubbed `Math.random`, and print the seed on failure.
// test/setup.js, capture evidence only when a test actually fails
exports.mochaHooks = {
afterEach() {
const t = this.currentTest;
if (t.state !== 'failed') return;
console.error(JSON.stringify({
title: t.fullTitle(),
retry: t.currentRetry(),
durationMs: t.duration,
tz: process.env.TZ,
node: process.version,
openHandles: process.getActiveResourcesInfo(),
}));
},
};
# Reproduce the CI box, not the CI machine
docker run --rm --cpus=2 -e TZ=UTC -e LANG=C -v "$PWD":/app -w /app \
node:22 npx mocha --sort
# Quantify the flake rate before spending a day on it
for i in $(seq 1 50); do npx mocha --fgrep 'refunds a captured payment' \
> /dev/null 2>&1 || echo "FAIL run $i"; done
Q34How do you migrate a Mocha test suite to Jest or Vitest?
AdvancedMigration
Answer
Decide first whether you should. Mocha is stable, well-supported, and works fine, many teams migrate for cosmetic reasons and regret the lost weeks. Real reasons to migrate: (1) you want snapshot testing built-in, (2) you want zero-config TypeScript, (3) you've adopted Vite and want one tool.
Migration path: Step 1: Codemod the API surface. `describe`/`it` are identical; rename `before`/`after` → `beforeAll`/`afterAll`. Step 2: Replace Chai assertions with Jest/Vitest's `expect()`, `jest-codemods` automates most of this. Step 3: Replace Sinon with Jest mocks (`jest.fn()`, `jest.spyOn()`).
This is the biggest rewrite, Sinon stubs and Jest mocks have different reset semantics. Step 4: Move `.mocharc` config to `jest.config.js` or `vitest.config.ts`. Step 5: Update CI scripts, coverage tool, reporters.
Budget 2-4 weeks for a 200-file suite. Run both runners in parallel for 1-2 sprints to catch regressions before deleting Mocha. Keep an explicit mapping table, because the pieces that do not codemod cleanly are where regressions hide. `this.timeout(5000)` becomes `jest.setTimeout()` or Vitest's `{ timeout: 5000 }` third argument; `this.retries(2)` becomes Vitest's `retry` option and has no direct Jest equivalent; `this.skip()` becomes `ctx.skip()`; the `mochaHooks` root hook plugin becomes `setupFilesAfterEach` in Jest or `setupFiles`/`globalSetup` in Vitest; `--grep` becomes `-t`; `.mocharc` `require` becomes `setupFiles`.
The assertion swap carries a real semantic trap: Chai's `expect(a).to.equal(b)` is strict identity, so the mechanical rewrite to `toBe` is right for primitives but wrong for the many places where teams used `to.eql` or `to.deep.equal`, which map to `toEqual`. Get that backwards and tests keep passing while asserting the wrong thing. Sinon differs too: `sandbox.restore()` puts originals back, whereas `vi.clearAllMocks()` only clears call history, so set `restoreMocks: true` in the config rather than trusting habit. Verify the migration by coverage delta and test count, not by a green run, since a mistranslated glob that drops 40 files also comes out green.
// vitest.config.ts, the Mocha settings that need a home
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // keeps bare describe/it working
include: ['test/**/*.test.ts'], // was .mocharc `spec`
setupFiles: ['test/setup.ts'], // was .mocharc `require`
testTimeout: 10000, // was `timeout`
restoreMocks: true, // replaces sandbox.restore()
retry: 0, // was this.retries()
coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
},
});
// Chai to expect: the mapping that silently changes meaning
// expect(a).to.equal(b) -> expect(a).toBe(b) (identity)
// expect(a).to.deep.equal(b) -> expect(a).toEqual(b) (structural)
// expect(fn).to.throw(/x/) -> expect(fn).toThrow(/x/)
// await expect(p).to.be.rejectedWith(E) -> await expect(p).rejects.toThrow(E)
Q35How do you handle test isolation when tests share a database?
AdvancedIntegration Testing
Answer
Three patterns, in order of robustness: (1) **Transaction-wrap every test**, start a transaction in `beforeEach`, roll back in `afterEach`. Fastest, perfect isolation, but breaks if your code under test commits its own transactions. (2) **Truncate tables in `beforeEach`**, works regardless of how the code uses transactions, but ~10× slower than rollback. Use `TRUNCATE ...
RESTART IDENTITY CASCADE` in Postgres to also reset sequences. (3) **Schema-per-worker**, `--parallel` workers each get a unique schema (`test_worker_${WORKER_ID}`), migrations run once at process start. Best for parallel runs. The wrong pattern is 'just hope tests don't interfere', that's how you get 3am pages because the test that mutates global state shipped to production.
For a fintech-style codebase using MySQL or Postgres, pattern (1) + migrations cached via Docker volume is the typical 2026 stack. The detail that breaks pattern (1) in practice is the connection pool. A transaction lives on one connection, so if the hook runs `BEGIN` on a pooled client while the code under test checks out a different connection, it cannot see the uncommitted fixture rows and you get a mystifying 'row not found' in a test that clearly just inserted it.
Hand the code the exact client the hook opened, or shrink the pool to `max: 1` in the test environment. MySQL adds a second trap: DDL statements cause an implicit commit, so any test that creates a table or runs a migration escapes the rollback and pollutes every later test. For pattern (2), `TRUNCATE` on many tables under `--parallel` deadlocks readily; truncate in a fixed table order, or truncate the whole set in one statement so the locks are taken atomically. A faster variant of (3) on Postgres is template databases: migrate once into `app_test_template`, then `CREATE DATABASE app_test_w3 TEMPLATE app_test_template` per worker, which copies at the file level and is far quicker than replaying migrations in every worker.
// test/setup.js, root hook plugin
exports.mochaHooks = {
async beforeAll() {
this.db = await pg.connect(process.env.TEST_DATABASE_URL);
await runMigrations(this.db);
},
async beforeEach() {
await this.db.query('BEGIN');
},
async afterEach() {
await this.db.query('ROLLBACK');
},
async afterAll() {
await this.db.end();
},
};
Q36What are the most subtle Mocha pitfalls senior engineers should know?
AdvancedGotchas
Answer
Six classics that cost real time. (1) **`done` + promise mix**, Mocha throws 'Resolution method overspecified' when a test returns a promise AND uses `done`. Pick one. (2) **Arrow functions in tests**, lose access to `this.timeout()`, `this.retries()`, `this.skip()`. Use `function()` syntax in tests, arrows fine for non-test callbacks. (3) **Hooks before any `it`**, a `beforeEach` declared outside a `describe` becomes a root hook with different semantics post-v8. (4) **Forgetting to `await` an `assert.rejects()`**, test passes regardless because the unhandled rejection happens after Mocha records 'success'.
ESLint's `no-floating-promises` catches it; turn that rule on. (5) **Shared Sinon stubs without `sandbox.restore()`**, stubs leak to the next test, causing 'works alone, fails after running other tests' flakes. Always use `sinon.createSandbox()` per test. (6) **Tests that mutate `process.env`**, without restoring in `afterEach`, you've created order-dependent tests. Snapshot env in `beforeEach`, restore in `afterEach`.
Interviewers at Coinbase and LinkedIn ask about (1), (2), and (4) specifically. Three more that only show up once a suite is large. (7) **Zero matched tests exits 0**, so a typo in a shard's `--grep`, a glob the shell expanded before Mocha saw it, or an `extension` that does not match your `spec` produces a green CI job that ran nothing. Assert a minimum test count, or check the resolved list with `--dry-run`. (8) **`--exit` as a permanent fix**, added to stop a hanging run, it papers over a leaked database pool or an open server and can also cut short an async coverage or reporter flush, so the fix is to close the handle and keep `--exit` out of the config. (9) **`this.timeout(0)` left behind after a debugging session**, which does not fail anything locally but turns a deadlocked test into a job that runs until the CI provider's own limit kills it, wasting the entire run's minutes with no useful output. (10) **A failure inside an `after` hook** prints below the suite's results, so anyone scrolling to the first red line misses it entirely.
// Snapshot and restore process.env so tests stay order-independent
let envSnapshot;
beforeEach(() => {
envSnapshot = { ...process.env };
process.env.FEATURE_UPI_AUTOPAY = 'true';
});
afterEach(() => {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, envSnapshot);
});
// CI flags that turn each pitfall into a build failure instead of a mystery
// .mocharc.ci.cjs
module.exports = {
...require('./.mocharc.json'),
'forbid-only': true, // a committed it.only fails the build
'forbid-pending': true, // so does a permanently pending test
'check-leaks': true, // catches new globals
'async-only': true, // every test returns a promise or takes done
};
Key Points
- Never mix `done` with returning a promise
- Use `function()` not arrow in tests when you need `this`
- Always `await` async assertions
- Use Sinon sandboxes, restore after each test
- Snapshot/restore `process.env` in test setup
Q37Every test passes but the Mocha process never exits. How do you find the open handle?
AdvancedDebugging
Answer
Since v4, Mocha no longer calls `process.exit()` when the runner finishes: it waits for the event loop to drain. So a leaked handle produces a run that prints '184 passing' and then hangs until the CI job's own limit kills it, burning the full timeout budget and reporting a failure that has nothing to do with any assertion. State the distinction clearly in an interview: the tests did not fail, the process failed to exit.
Diagnose before patching. In a root `afterAll`, print `process.getActiveResourcesInfo()` (Node 17+), which lists the resource types still holding the loop open, typically 'TCPSocketWrap', 'Timeout' or 'FSReqCallback'. That tells you the category; for the creation stack, `why-is-node-running` or `wtfnode` names the file and line, which is what actually ends the hunt.
The usual culprits, roughly in order of frequency: an Express `app.listen()` that ran because `app.js` and `server.js` were never split, a `pg` or `mysql2` pool and a Redis client that were opened in `before` and never `.end()`ed, a metrics `setInterval` (fix it with `timer.unref()`, which lets the process exit while the timer keeps working in production), a keep-alive `http.Agent` holding sockets open until `agent.destroy()`, and Sinon fake timers left installed. `--exit` forces the process down and is a legitimate escape hatch for a third-party handle you genuinely cannot close, but treat it as a documented exception: it hides a leak that also exhausts connections in the long-running service, and it can truncate an async flush from a coverage or JUnit reporter, leaving you a half-written XML file. Under `--parallel`, one worker that will not exit stalls the whole run.
// test/setup.js, root hook plugin: shut things down, then prove it worked
exports.mochaHooks = {
async afterAll() {
await pool.end();
await redis.quit();
metricsTimer.unref();
const open = process.getActiveResourcesInfo()
.filter(r => !['TTYWrap', 'Immediate'].includes(r));
if (open.length) {
console.error('Handles still open after teardown:', open);
}
},
};
# name the culprit instead of guessing
npx mocha --require why-is-node-running/include
# last resort, and it belongs in a comment with a ticket number
npx mocha --exit
Key Points
- Mocha v4 removed the implicit process.exit(); a hang means a live handle
- `process.getActiveResourcesInfo()` for the type, why-is-node-running for the stack
- Servers, DB pools, Redis clients, intervals, keep-alive agents
- `--exit` masks the leak and can truncate reporter output
Q38How do you write a custom Mocha reporter, and what changes under `--parallel`?
AdvancedReporters
Answer
A reporter is a class that takes `(runner, options)` and subscribes to runner events. Extend `Mocha.reporters.Base` so you inherit `this.stats` (passes, failures, pending, duration) and the colour helpers, then listen using the named constants from `Mocha.Runner.constants` rather than raw strings: `EVENT_RUN_BEGIN`, `EVENT_SUITE_BEGIN`, `EVENT_TEST_PASS`, `EVENT_TEST_FAIL`, `EVENT_TEST_PENDING`, `EVENT_RUN_END`. Call `super(runner, options)` first or `this.stats` is never populated.
Load it with `--reporter ./tools/reporters/slow-tests.cjs` (a path, not a package name) and read custom settings from `options.reporterOption`, which is populated by repeated `--reporter-option key=value` flags. Typical real uses are posting a failure digest to Slack, writing a per-test duration file for the CI dashboard, and emitting the internal format your test-analytics tool expects. Parallel mode changes three things.
Events no longer arrive as one interleaved stream: each worker buffers its results and Mocha replays them per file as workers finish, so output is grouped by file and globally out of order, and anything that assumes monotonic progress (a spinner, a percentage bar, an incrementing index) misbehaves. Objects crossing the process boundary are serialized, so `test.fn` is gone, circular references are stripped, and the error you receive is a plain object: assert on `err.message`, `err.stack`, `err.expected` and `err.actual` rather than `instanceof MyError`. Finally, `EVENT_RUN_END` is synchronous and the process may exit immediately after, so an HTTP POST fired from there can be cut off mid-flight; write a file in the reporter and upload it as a separate CI step.
// tools/reporters/slow-tests.cjs
const Mocha = require('mocha');
const fs = require('node:fs');
const { EVENT_TEST_END, EVENT_RUN_END } = Mocha.Runner.constants;
module.exports = class SlowTests extends Mocha.reporters.Spec {
constructor(runner, options) {
super(runner, options);
const limit = Number(options.reporterOption?.limit ?? 20);
const out = options.reporterOption?.output ?? 'slow-tests.json';
const rows = [];
runner.on(EVENT_TEST_END, test => {
rows.push({ title: test.fullTitle(), ms: test.duration ?? 0, state: test.state });
});
runner.once(EVENT_RUN_END, () => {
rows.sort((a, b) => b.ms - a.ms);
// write synchronously: the process can exit right after this event
fs.writeFileSync(out, JSON.stringify({
totalMs: this.stats.duration,
failures: this.stats.failures,
slowest: rows.slice(0, limit),
}, null, 2));
});
}
};
# npx mocha --reporter ./tools/reporters/slow-tests.cjs \
# --reporter-option limit=30 --reporter-option output=reports/slow.json
Q39What changed across Mocha v4, v6, v8, v9, v10 and v11 that you must plan for in an upgrade?
AdvancedVersions
Answer
The upgrades that actually break things are behavioural, not API renames. **v4** removed the implicit `process.exit()` after the run and added `--exit`; suites with a leaked database pool went from 'finishes fine' to 'hangs forever', which is still the most common surprise in an old codebase. **v6** introduced the `.mocharc.*` config files and deprecated `test/mocha.opts`. **v8** is the big one: `--parallel` with per-file worker processes, root hook plugins (`mochaHooks`) as the supported way to share hooks across files, native ESM test files, and the removal of `mocha.opts`, so any project still relying on that file goes from configured to default-configured silently. **v9** added `--dry-run` and raised the Node floor. **v10** required Node 14+ and dropped the built-in growl desktop notifications. **v11** requires Node 18.18+ and drops Node 16, which for most teams is the only real work: the runtime bump, not Mocha. The practical playbook is the same each time. Before upgrading, record the baseline: `npx mocha --dry-run --reporter json | jq '.tests | length'` gives you a test count that must not change.
Upgrade Mocha alone, keeping assertion and mocking libraries pinned, and run once. Then compare the count, because a discovery change shows up as fewer tests rather than as an error, and Mocha exits 0 when nothing matched. Flip `--parallel` last and as a separate commit, since that is where root hooks defined inside test files quietly stop applying and where `--file` and `--delay` stop being accepted.
# 1. baseline before touching anything
npx mocha --dry-run --reporter json | jq '.tests | length' > .test-count
# 2. upgrade the runner only, leave chai/sinon pinned for now
npm i -D mocha@11
node -v # v11 needs >= 18.18.0
npx mocha --version
# 3. the count must match, a drop means discovery changed, not tests
test "$(npx mocha --dry-run --reporter json | jq '.tests | length')" \
= "$(cat .test-count)" || echo 'DISCOVERY CHANGED'
// 4. only then, and as its own commit, move hooks into a root hook plugin
// test/setup.js
exports.mochaHooks = {
async beforeAll() { global.db = await connectTestDb(); },
async afterAll() { await global.db.close(); },
};
// .mocharc.json -> { "require": ["test/setup.js"], "parallel": true }
Key Points
- v4: no implicit process.exit(), `--exit` added
- v8: --parallel, root hook plugins, native ESM, mocha.opts removed
- v9: --dry-run; v10: Node 14+ and growl removed; v11: Node 18.18+
- Compare `--dry-run` test counts before and after, a silent drop exits 0
Q40A Mocha suite takes 25 minutes of CI time per push. How do you profile it and cut the cost?
AdvancedPerformance
Answer
Measure first, because intuition about slow tests is reliably wrong. `mocha --reporter json` emits a `duration` for every test, so `jq '.tests | sort_by(-.duration) | .[0:20]'` gives you the real ranking in one command; `this.slow()` only tints the spec output and measures nothing. The distribution is nearly always long-tailed: a dozen tests, usually the ones with real `setTimeout` sleeps or a container start inside `beforeEach`, own most of the clock. Then attack in order of payoff.
Replace literal sleeps with Sinon fake timers or an `await once(emitter, 'ready')`; `grep -rn 'setTimeout(.*[0-9]\{4\}' test/` finds them. Move anything created per test that could be created once per suite into `before`, and anything created per suite that could be created once per worker into a root hook. Swap `ts-node/register` for `@swc-node/register`, which mostly removes the type-checking cost from the test path (keep `tsc --noEmit` as its own CI job).
Split the fast layer from the slow one so `npm run test:unit` runs on every push and integration runs on pull requests only. Then be precise about parallelism versus cost, which is the part interviewers listen for: `--parallel` and CI sharding cut wall-clock time but bill roughly the same runner minutes, because four shards of six minutes is still twenty-four minutes of compute. Latency and money are different budgets.
What reduces both is deleting redundant coverage, caching `~/.npm` on the lockfile hash, and reusing a migrated database template instead of replaying migrations in every worker. Also set `--jobs` from the runner's real core count (`nproc`), not your laptop's, or workers thrash and borderline tests start timing out.
# rank the 20 slowest tests
npx mocha --reporter json --reporter-option output=report.json
jq -r '.tests | sort_by(-.duration) | .[0:20][] |
"\(.duration)ms \(.fullTitle)"' report.json
# total time per file, to decide what to split
jq -r '.tests | group_by(.file) | map({file: .[0].file,
ms: (map(.duration) | add)}) | sort_by(-.ms) | .[0:10][] |
"\(.ms)ms \(.file)"' report.json
# find the literal sleeps
grep -rn 'setTimeout(.*[0-9]\{4\}' test/
# match jobs to the runner, not the laptop
npx mocha --parallel --jobs "$(nproc)"
Frequently Asked Questions
Is Mocha still relevant in 2026?
Yes, especially in mature Node.js codebases. Jest dominates React/Next.js, and Vitest is winning Vite-based projects, but Mocha remains the default for backend Node.js services that started before 2020. Companies like LinkedIn, Postman, Coinbase, and many Indian fintechs (Razorpay, Cred) still run Mocha at scale. Mocha v11 (2025) added solid ESM and TypeScript support, keeping it competitive.
How much does a Node.js developer with Mocha experience earn in India?
₹5-16 LPA in 2026, depending on seniority and stack. Junior Node.js + Mocha roles start around ₹5-8 LPA; senior backend engineers at fintech (Razorpay, Cred, PhonePe) or product companies (Postman, Hasura) reach ₹14-16 LPA and beyond. Mocha by itself isn't a high-paying skill, it's the broader Node.js/TypeScript/testing competence that pays.
Should I learn Mocha or Jest first in 2026?
Learn Jest if you're targeting frontend/full-stack React jobs, it's the React community default. Learn Mocha if you're targeting backend Node.js roles, especially at companies with established codebases. The describe/it concepts transfer between both, so picking up the second one takes a weekend. Vitest is a third option if you're working in a Vite/Vue/Svelte/Next 13+ codebase, its API is Jest-compatible but much faster.
Which assertion library pairs best with Mocha?
Three solid choices in 2026: (1) Node's built-in `assert/strict`, zero deps, simple, the modern default for new projects. (2) Chai, most popular in legacy codebases, with three styles (assert/expect/should) and a rich plugin ecosystem (chai-as-promised, sinon-chai). (3) Vitest's `expect` via a small shim, if you want Jest-compatible matchers without Jest. For new Mocha projects, start with `node:assert/strict` unless you specifically want Chai's chainable syntax.
Can Mocha run TypeScript tests directly without compiling?
Yes, with a loader. Three options in 2026: `ts-node/register` (slowest but most compatible), `@swc-node/register` (10-20× faster, recommended), or `tsx` (Node-native, very fast). Add it to `require` in `.mocharc`. With Node 22.6+, you can also use `--experimental-strip-types` to skip the loader entirely for pure TypeScript-syntax files without runtime type features.
What Node.js version does Mocha v11 need?
Mocha v11 requires Node 18.18.0 or newer and drops Node 16. For most teams that is the whole upgrade: the runtime bump, not the runner. Check with `node -v` and `npx mocha --version` before debugging anything else, and avoid a global Mocha install, because a global v10 shadowing a project's v11 produces flag errors that look like config problems.
Why does my Mocha run hang after printing 'all tests passing'?
Something is still holding the event loop open. Mocha stopped calling `process.exit()` in v4, so it waits for the loop to drain. Print `process.getActiveResourcesInfo()` in an `after` hook, or run with `why-is-node-running`, to see what is open. Usual causes: an Express `app.listen()`, a database pool or Redis client never closed, a `setInterval` that needs `.unref()`, or a keep-alive HTTP agent. `--exit` forces the process down but hides the leak and can truncate reporter output.
Is Mocha slower than Jest or Vitest?
Not inherently. Mocha's own startup is light because it does no transform work; the cost lives in whatever you bolt on, which is why `ts-node/register` versus `@swc-node/register` swings cold start far more than the runner choice does. Where Jest and Vitest win by default is isolation and change detection: both parallelise per file out of the box and Vitest re-runs only affected files on watch. Mocha reaches similar wall-clock numbers with `--parallel --jobs`, a fast TypeScript loader and split unit/integration configs, but you have to set those up yourself.
Can I use Mocha with ESM-only packages like Chai 5?
Yes, if your tests are ESM. Mocha loads `.mjs` files and packages with `"type": "module"` using dynamic `import()`. A CommonJS suite has two choices: pin `chai@4`, or load Chai with `const { expect } = await import('chai')` inside a `before` hook. The bigger ESM consequence is mocking, since `sinon.stub()` cannot rewrite an immutable module binding, so plan on `esmock` or dependency injection.
Introduction
Mocha is the most established JavaScript test framework, in continuous production use since 2011. While Jest captured the React ecosystem and Vitest is winning the Vite generation, Mocha remains the workhorse of mature Node.js codebases, particularly older fintech, payment gateways, and library projects where its unopinionated, composable design is a feature rather than a limitation.
If you're interviewing for a Node.js role in India in 2026, especially at companies like Razorpay, Postman, or LinkedIn that maintain large legacy services, Mocha questions are still common. Interviewers probe knowledge of describe/it structure, hooks, async testing patterns, and how Mocha pairs with Chai, Sinon, and c8 to form a complete test stack.
This guide covers the 40 most-asked Mocha interview questions in 2026, grouped by difficulty. Each answer includes the underlying concept, common gotchas, and a code example where it adds clarity.
Ready to practice Mocha interviews?
Don't just read, practice these Mocha questions live with an AI interviewer that asks follow-ups and scores your answers.