Mocha Retries: A Practical Way to Live With Flaky Tests (Without Hiding Them)
Mocha's built-in retries (this.retries and --retries) can keep CI green despite flaky tests — if you scope them narrowly, avoid arrow functions, and treat every retry as a bug to fix.
31 Aug 2025, 05:33 UTC

Your CI run fails on a test that passes every time you run it locally. You re-run the pipeline and it goes green. Sound familiar? That is a flaky test, and Mocha ships with a built-in feature — test retries — that can keep your pipeline moving while you hunt down the root cause. The takeaway: use this.retries() narrowly on the tests that genuinely need it, log every retry, and treat each one as a bug report, not a fix.
What Mocha retries actually do
When a test fails, Mocha normally marks it failed and moves on. With retries enabled, Mocha re-runs that same test up to n additional times. If any attempt passes, the test is reported as passing, with the retried attempts noted in the output. If every attempt fails, the test fails as usual — retries do nothing for a genuinely broken test, they just make it slower.
You can enable retries in two ways:
- Per test or per suite in code: call
this.retries(n)inside anit()or adescribe(). Setting it on adescribeapplies to every test and hook inside that block. - Globally from the CLI: run
npx mocha --retries 2. This applies to the whole run, which is usually broader than you want.
The arrow-function trap
Retries (and timeouts) rely on Mocha's test context, which is exposed through this. Arrow functions do not bind their own this, so this.retries(2) inside it('...', () => { ... }) will not work. Use classic function() syntax anywhere you touch Mocha's context:
// Works
describe('payment gateway', function () {
this.retries(2); // applies to all tests in this suite
it('charges a card', function () {
this.timeout(5000);
// ...
});
});
// Broken: this.retries is undefined here
it('charges a card', () => {
this.retries(2); // TypeError or silent no-op depending on context
});A worked example: a flaky integration test
Suppose you have an integration test that boots a local HTTP server and occasionally loses a startup race — the request fires before the server is listening. Here is a targeted setup:
const assert = require('node:assert');
const { startServer, stopServer } = require('./helpers/server');
describe('local API', function () {
// Each attempt gets fresh setup/teardown
beforeEach(async function () {
this.server = await startServer({ port: 0 });
});
afterEach(async function () {
await stopServer(this.server);
});
it('responds to /health', async function () {
this.retries(2); // up to 3 total attempts
this.timeout(4000); // per attempt
const res = await fetch(`http://127.0.0.1:${this.server.port}/health`);
assert.strictEqual(res.status, 200);
});
});Run it from your project directory with your normal test command (no special permissions needed):
npx mocha test/api.spec.jsTwo things to notice. First, beforeEach and afterEach re-run for every attempt, so each retry gets a fresh server — essential if your test mutates shared state, but it also means retries multiply your setup cost. Second, this.timeout() applies per attempt, so a retried slow test can take 2–3x its timeout in wall-clock time.
To verify the mechanism works before trusting it, write a deliberately flaky test:
it('passes on the second attempt', function () {
this.retries(1);
this.constructor.__calls = (this.constructor.__calls || 0) + 1;
assert.strictEqual(this.constructor.__calls % 2, 0);
});You should see Mocha report it as passing with a retry indicated. Then run npx mocha --retries 2 against a test that always fails and confirm it still fails after all attempts — that confirms retries are not masking deterministic breakage.
The trade-off: mitigation, not medicine
Retries hide timing races, network hiccups, and order-dependent state — they fix none of them. The risks:
- Global
--retrieshides systemic flakiness. If half your suite needs a retry to pass, your suite is telling you something. Prefer per-suite or per-test retries. - CI runtime inflates. Every retry re-runs hooks and the test body. A few retried tests are fine; a suite-wide retry policy can add real minutes.
- Silent decay. A test that passes on attempt three every run looks green in CI. Without tracking, you will never fix it.
The practical countermeasure is visibility. Mocha's spec reporter marks retried tests in its output, so the simplest tracking step is to grep CI logs for retry indicators and file a ticket for each recurring offender. If you need structured data, the JSON reporter (--reporter json) includes per-attempt results you can aggregate over time. Behavior and reporter details vary across Mocha major versions, so confirm against the version pinned in your project (npx mocha --version).
What to do this week
Pick your three flakiest tests, add targeted this.retries(2) with function() syntax, and add a CI log check that flags any test that needed a retry. Give each flagged test an owner and a deadline to fix the root cause. Retries buy you a green pipeline and breathing room — spend that room fixing the tests, not forgetting them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.