Duplicate writes from suite‑wide retry causing non‑idempotent test side effects
0 reputation · 18 Jun 2023, 04:20 UTC
0 reputation · 18 Jun 2023, 04:20 UTC
Mocha’s global `--retries` flag reruns the entire test suite after a failure, which repeats all `before` hooks for each retry. If a test performs non‑idempotent writes such as inserting records, the side effects can be duplicated across retries.
The documentation states that retries execute after the failing test’s `after` hooks, but it does not specify whether earlier `before` side effects are re‑executed, creating uncertainty about write duplication.
How can Mocha provide fine‑grained retry control per test without re‑executing `before` side effects? Is there a configuration to limit retries to the failing test and its `after` hooks only? Can a per‑test retry API be added to avoid duplicate writes?
35275 reputation · 18 Jun 2023, 15:04 UTC
Can Mocha retry only the failing test without re-running before hooks? No. The --retries flag operates at the suite (file) level. Every retry re-executes the entire test file context, including all before, beforeEach, afterEach, and after hooks.
Is there a configuration to limit retries to the failing test and its after hooks? No such configuration exists in Mocha 10.x or earlier. The retry mechanism is implemented in the runner and always restarts the full suite.
Can a per-test retry API be added to avoid duplicate writes? Mocha exposes this.retries(n) inside a test to set the retry count, but it does not expose any API to control retry scope. Feature requests for test-level retry isolation have been open for years; maintainers consistently recommend making tests idempotent or restructuring hooks.
Mocha's documentation states: "If a test fails, Mocha will retry the test up to n times." In practice, "the test" means the entire test file when --retries is passed on the command line, or the current suite when set programmatically. Each retry creates a fresh run of the suite hierarchy, so before hooks fire again. This is by design, not a bug.
beforeEach with matching afterEach cleanuplet db;
before(async () => { db = await connect(); }); // expensive, immutable
beforeEach(async () => { await db.truncate('widgets'); }); // clean slate each attempt
afterEach(async () => { await db.truncate('widgets'); }); // cleanup on failure too
it('inserts a widget', async () => { await db.insert('widgets', { name: 'A' }); });
Each retry gets a clean table. The cost is running truncate/cleanup on every attempt.
// Use upsert / ON CONFLICT DO NOTHING / unique constraints
await db.query(`INSERT INTO widgets (id, name) VALUES (1, 'A') ON CONFLICT (id) DO NOTHING`);
Duplicate executions become harmless. This is the maintainers' preferred approach.
const retry = require('async-retry');
it('flaky external call', async () => {
await retry(async (bail) => {
const res = await fetch('/api/flaky');
if (!res.ok) throw new Error('flaky');
return res.json();
}, { retries: 3, minTimeout: 100 });
});
Only the test body retries; hooks run once. Trade-off: you lose Mocha's built-in retry reporting (attempt numbers in reporters).
before, mutate only in beforeEachlet schema;
before(async () => { schema = await loadSchema(); }); // once per file
beforeEach(() => { db = schema.createTestDb(); }); // fresh per attempt
afterEach(() => db.close());
Are the duplicate writes hitting a shared external database used by other test files or processes, or a per-test temporary database that is torn down after the suite? If shared, workaround 1 or 2 is required. If temporary, you can also consider running the suite in a transaction and rolling back in after (though Mocha retries break transaction boundaries).
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 18 Jun 2023, 14:15 UTC
Mocha's retry mechanism (both global --retries and per-test this.retries(n) since v8.0.0) always re-executes the test's beforeEach/before hooks on each attempt, then runs afterEach/after between retries. This is intentional: each retry gets a fresh hook chain to avoid state leakage.
There is no configuration flag to retry only the test body while skipping before hooks. The documented workarounds are:
beforeEach setup idempotent (e.g., DELETE then INSERT).Parallel mode (--parallel) compounds the issue because retries run in separate workers, each re-running the full hook chain.