Skip to content

Flaky tests in Jest.
Named, fixed, and quarantined.

Flaky Jest suites are not random. They follow patterns: fake-timer leakage, snapshot drift, mock hoisting, concurrent state. Name them, fix them, quarantine what is left.
Your CI stays green.

By Rémy Duthu, Software Engineer, CI Insights · Updated

mergify[bot] commented · 2 minutes ago Flaky test detected checkout flow › settles the pending promise src/checkout.test.ts:42 Last 3 runs on this commit: ✕ Failed ✓ Passed ✓ Passed Confidence on main: 98% 71% over the last 7 days Auto-quarantined by Test Insights This test no longer blocks your merge. Quarantine lifts when stable.
Example PR comment from the Mergify bot detecting a flaky Jest test and quarantining it automatically.

Why Jest is uniquely flaky

Jest ships a lot of useful machinery: fake timers as a first-class primitive, automatic mock hoisting, snapshot serialization, and a choice of two default environments (node or jsdom). Every one of those features pays for itself. Every one of them is also a flake surface if you lean on it without cleaning up after yourself.

The common thread is implicit shared state. Timers, mocks, module caches, the window object, snapshot files: they all persist across tests by design, and Jest's test-file-level parallelism means stale state from one file can leak into the next worker. That is where most "passes locally, fails in CI" stories come from. Not randomness, not the network, not the moon phase.

The patterns are finite. We've seen the same eight on Mergify Test Insights across hundreds of Jest suites: fake-timer leakage, snapshot drift on shared state, module-mock hoisting traps, test.concurrent state sharing, unhandled promise leaks, jest.retryTimes() hiding real bugs, environment bleed between node and jsdom, and globalSetup hooks that forgot to await. Each has a clean fix once you can name it.

The 8 patterns behind most flaky suites

Pattern 1

Fake-timer leakage

Symptom. A test passes in isolation, fails when the suite runs end to end, and the failure looks like a completely unrelated assertion timeout.

Root cause. Calling jest.useFakeTimers() mutates a module-scoped timer mock that survives the test unless you explicitly restore it. setTimeout/setInterval callbacks queued under fake timers can fire under the next test's real timers, and vice versa. The next test sees phantom assertion runs, mock calls it did not make, or promise states that make no sense.

test("fake-timer leakage", () => {
  jest.useFakeTimers();
  setTimeout(() => fetchUser(), 5000);
  // no cleanup. fake timers leak into the next test
});

test("getUser returns the logged-in user", async () => {
  const user = await getUser();
  expect(user).toBeDefined(); // fetchUser from test 1 fires here
});

Fix. Pair every useFakeTimers() with useRealTimers() in afterEach, and clear pending timers before the switch. If you use fake timers everywhere, move it into a global setup and stop flipping per-test.

afterEach(() => {
  jest.clearAllTimers();
  jest.useRealTimers();
});

With Mergify. Test Insights reruns the suspect test on a dedicated worker. When the same SHA produces different results across runs, the test is flagged; when two of the three runs agree, the dissenter is quarantined so the merge queue keeps moving.

Pattern 2

Snapshot drift on shared state

Symptom. A snapshot test fails every few days with a diff you did not cause: a timestamp shifted, a UUID changed, a list reordered.

Root cause. toMatchSnapshot() serializes whatever you hand it. If the rendered output includes Date.now(), a crypto UUID, a Map iterated in insertion order, or a test-id from your component library, the snapshot captures today's value and tomorrow's run disagrees. The test has not changed. The world has.

test("renders invoice", () => {
  // Invoice renders 'Generated: {Date.now()}' and a random receipt UUID
  const tree = renderer.create(<Invoice />).toJSON();
  expect(tree).toMatchSnapshot();
});

Fix. Freeze the sources of non-determinism with a custom serializer or a jest.setSystemTime() stub. For UUIDs, mock the generator. For anything truly variable, assert on structure with property matchers rather than a full snapshot.

expect.addSnapshotSerializer({
  test: (v) => typeof v === "string" && UUID_RE.test(v),
  print: () => '"<uuid>"',
});

beforeAll(() => {
  jest.useFakeTimers().setSystemTime(new Date("2026-01-01"));
});

With Mergify. Test Insights groups the repeated diffs under a single flaky-test confidence score so you see the pattern at once: same test, same file, three drifting snapshots in a week. Quarantine until you add the serializer.

Pattern 3

Module-mock hoisting traps

Symptom. A mock you thought you scoped to one file starts affecting a later file in the same worker, or your imported module is undefined on the second run.

Root cause. jest.mock('./db') is hoisted above imports at transform time. That works great when the factory is pure, but if the factory captures a closure variable or your mock caches state, the second test file that imports ./db gets the cached mock, not a fresh one. Jest workers reuse module caches across files by default.

// auth.test.ts
jest.mock("./db", () => ({ query: jest.fn().mockResolvedValue([]) }));
import { login } from "./auth";

test("login calls db.query", async () => { /* ... */ });

// billing.test.ts, same worker
import { charge } from "./billing";

test("charge hits the db", async () => {
  await charge();
  // expected: real db.query. actual: the mock from auth.test.ts
});

Fix. Reset modules between files with jest.resetModules(), or set resetMocks: true in your config so every test starts clean. If a mock is truly global, put it in __mocks__ where the behavior is explicit.

// jest.config.js
module.exports = {
  resetMocks: true,
  resetModules: true,
};

With Mergify. Test Insights detects the cross-file signature: a test that only fails when run after a specific other file. The dashboard surfaces the ordering dependency so you know where to look.

Pattern 4

test.concurrent state sharing

Symptom. A suite written with <code class="bg-gray-100 px-1 rounded">test.concurrent</code> fails intermittently with off-by-one counters, wrong user records, or assertions that see state they should not.

Root cause. test.concurrent runs tests in parallel inside a single file using Jest's promise scheduler. Any module-level variable they touch is shared. beforeEach reset hooks do not fire between concurrent tests. They fire before each of them, in order, which is not the same thing.

let counter = 0;

beforeEach(() => {
  counter = 0;
});

test.concurrent("increments to 1", async () => {
  counter++;
  expect(counter).toBe(1); // passes in isolation
});

test.concurrent("increments to 1 again", async () => {
  counter++;
  expect(counter).toBe(1); // fails ~50% of the time
});

Fix. Only use test.concurrent for tests that are pure functions of their inputs. If a test reaches for a shared mock, a database, or a module singleton, demote it to a regular test.

With Mergify. Test Insights surfaces concurrent-only failures distinctly: the test fails only when its file runs with --maxConcurrency > 1, and passes in sequential reruns. That signature is the fingerprint of shared-state parallelism, and the dashboard labels it.

Pattern 5

Unhandled promise leaks

Symptom. A test completes green, then a later test fails with an error message that mentions something the earlier test was doing.

Root cause. A test that calls .then() without returning or awaiting the promise finishes before the promise resolves. The resolution lands in the next test's microtask queue, where it can mutate shared state, call a mock that now belongs to a different test, or trigger an unhandledRejection that Jest attributes to the wrong test.

test("fetches the user", () => {
  fetchUser().then((u) => {
    // this resolves after the test body has already returned
    expect(u.name).toBe("Rémy");
  });
});

Fix. Always await or return promises from test bodies. Turn on the @typescript-eslint/no-floating-promises rule to catch this in review.

test("fetches the user", async () => {
  const u = await fetchUser();
  expect(u.name).toBe("Rémy");
});

With Mergify. Test Insights reruns the failing test on a worker by itself. If the test passes alone but fails under the original schedule, the dashboard tags it with a 'sequencing-dependent' label and suggests the floating-promise pattern.

Pattern 6

Retry-hidden failures

Symptom. Your pipeline is green. Your users hit a bug in production that your tests were supposed to catch.

Root cause. jest.retryTimes(n) re-runs a failing test up to n times and reports the last result. A real bug that fails on the first attempt and passes on the second (because of caching, warm-up, or a race it happens to lose locally) gets reported as green. Retries at the Jest level are a tempting knob; they paper over the exact kinds of failures that CI is supposed to catch.

// jest.config.js (please don't)
module.exports = {
  setupFilesAfterEach: ["<rootDir>/jest.setup.js"],
};
// jest.setup.js
jest.retryTimes(3, { logErrorsBeforeRetry: false });

Fix. Do not retry at the Jest level. When a test is genuinely flaky, diagnose and fix it. When diagnosis takes more than a session, quarantine it instead. That keeps the signal visible without blocking merges.

With Mergify. Test Insights reruns at the CI level with attempt-level result tracking. You see that a test passed on attempt 2 of 3, which is exactly the information retryTimes() throws away. Quarantine kicks in once the pattern is clear.

Pattern 7

Environment bleed

Symptom. A test passes in your file but throws <code class="bg-gray-100 px-1 rounded">ReferenceError: document is not defined</code> in another file that tests the same component.

Root cause. Jest lets you choose between the node and jsdom environments per file with a @jest-environment pragma. Forget the pragma on a file that needs DOM globals and the test throws on load. Keep the pragma on a file that no longer needs DOM and you pay the jsdom startup cost on every run for no reason.

// ComponentA.test.tsx: has the pragma, works
/**
 * @jest-environment jsdom
 */
import { render } from "@testing-library/react";

// ComponentB.test.tsx: forgot the pragma
import { render } from "@testing-library/react";
test("renders", () => {
  render(<B />); // ReferenceError: document is not defined
});

Fix. Set testEnvironment: "jsdom" in jest.config.js for your UI test directory via projects, and leave node as the default for everything else. Per-file pragmas should be the exception, not the rule.

// jest.config.js
module.exports = {
  projects: [
    { displayName: "unit", testEnvironment: "node", testMatch: ["<rootDir>/src/**/*.test.ts"] },
    { displayName: "ui",   testEnvironment: "jsdom", testMatch: ["<rootDir>/src/**/*.test.tsx"] },
  ],
};

With Mergify. Environment misconfigurations manifest as consistent failures when the file runs alone, not as intermittent flakes. Test Insights still catches them through confidence scoring on the default branch, surfacing the file as broken rather than flaky.

Pattern 8

globalSetup hook races

Symptom. The first test of a new CI run fails with a 'table does not exist' or 'fixture not found' error. A rerun of the same SHA passes.

Root cause. globalSetup runs once before the suite. If its function returns before the async work it kicked off is done, Jest starts your tests against a half-seeded database or a half-built temp directory. The first few tests fail; by the time the setup actually completes, later tests see a consistent world.

// setup.ts: the bug
export default function () {
  seedDatabase(); // returns a promise, never awaited
}

Fix. Make the setup function async and await every side effect. Jest blocks until the returned promise resolves.

export default async function () {
  await seedDatabase();
}

With Mergify. Test Insights notices that the first N tests of a run fail consistently only on cold CI workers, which is the fingerprint of a setup race. The dashboard flags the ordering and the subset of affected tests.

Detection

Catch every Jest flake in CI

Point Jest at the jest-junit reporter, upload the resulting XML to Mergify with a one-line CLI call, and Test Insights builds a confidence score for every test on your default branch. PR runs are compared against that baseline. Anything inconsistent gets flagged in a PR comment before the author merges.

mergify ci
# 1. Add the jest-junit reporter
npm install --save-dev jest-junit

# 2. Emit JUnit XML on every CI run
jest --reporters=default --reporters=jest-junit

# 3. Upload the result (once, in CI)
curl -sSL https://get.mergify.com/ci | sh
mergify ci junit upload junit.xml

Prevention

Block flaky Jest tests at PR time

On every PR, Mergify reruns the tests whose confidence is below threshold, without jest.retryTimes() touching your config. The PR gets a comment naming the unreliable tests, their confidence history, and whether the failure on this PR is new or historical noise. Authors fix the real bugs before merge instead of re-running CI until it passes.

Mergify Test Insights Prevention view showing caught flaky Jest tests per PR

Quarantine

Quarantine without skipping

Once a Jest test is confirmed flaky, Test Insights quarantines it. The test still runs in the suite, no `describe.skip()` rewrite required, but its result no longer blocks merges or marks the pipeline red. When the pass rate on main recovers, quarantine lifts automatically and the test goes back to being load-bearing.

renders the invoice line Healthy login dispatches the right action Healthy checkout flow settles the pending promise Quarantined rate limiter rejects after 3 requests Healthy

Want to see which Jest tests in your repo are already flaky?

Works with jest-junit, jest-github-actions-reporter, or any JUnit-compatible Jest reporter. Setup takes under five minutes.

Book a discovery call

Frequently asked questions

Why are my Jest tests flaky in CI but pass locally?
Your laptop and your CI runner differ in CPU, parallelism, and timing. Tests that use real timers, touch the filesystem, or forget to await a promise lose the race under CI's tighter budget and hide on your laptop. Run the suite with `jest --runInBand --no-cache` locally to approximate CI behavior, then fix the underlying timing or isolation bug before you push.
How do I detect flaky Jest tests?
Jest alone cannot tell flaky from broken since each run gives one data point per test. You need to run the same commit multiple times and compare results. Mergify Test Insights does that on every PR and on the default branch, scores each test, and surfaces the tests whose pass rate drops below a confidence threshold.
Does jest.retryTimes() fix flaky tests?
No, it hides them. A test that fails on attempt 1 and passes on attempt 2 is still broken; you have only decided not to look at the failure. Use jest.retryTimes() as a temporary bandage for a test you are actively fixing, never as a permanent policy. For visibility without blocking the merge queue, quarantine instead of retry.
What causes flaky tests in Jest?
Eight patterns cover most of what we see: fake-timer leakage, snapshot drift on shared state, module-mock hoisting traps, test.concurrent state sharing, unhandled promise leaks, jest.retryTimes() hiding real bugs, environment bleed between node and jsdom, and globalSetup hooks that forgot to await. Each is covered above with a minimal reproducer.
How do I quarantine a flaky Jest test without deleting it?
Mergify Test Insights quarantines the test automatically once its confidence score drops. The test still runs in the suite, but a failing result no longer blocks merges and its noise no longer drowns out real signal. When the test stabilizes on main, quarantine lifts automatically. No `test.skip()`, no commented-out tests, no orphaned files.
Is test.concurrent flaky by default?
No, but it fails predictably on any shared module-level state: counters, caches, singletons, spies. `beforeEach` does not fire between concurrent tests (it fires before each of them, in order), so reset hooks do not protect concurrent state. Reserve `test.concurrent` for pure tests and demote anything that touches shared state to a regular `test`.

Keep digging

Deep dives on the patterns above

All posts →
Fake-timer leakage in Jest: the flake nobody sees coming
April 25, 2026 · 6 min read

Fake-timer leakage in Jest: the flake nobody sees coming

Why a single jest.useFakeTimers() call can pollute the next test in the file, what the failure looks like, and the three-line afterEach that makes it go away for good.

Rémy Duthu Rémy Duthu
Jest snapshot drift: when toMatchSnapshot lies about your code
April 25, 2026 · 7 min read

Jest snapshot drift: when toMatchSnapshot lies about your code

Snapshots fail every few days with diffs you did not cause. Most teams blame their Date.now() mock. The real story involves three less-obvious sources of drift, and a one-line serializer that fixes them.

Rémy Duthu Rémy Duthu
Why jest.retryTimes() is hiding bugs in your test suite
April 25, 2026 · 5 min read

Why jest.retryTimes() is hiding bugs in your test suite

Adding retryTimes(3) feels like fixing flakes. It is not. It is throwing away the data you need to find the bug, then telling CI everything is fine.

Rémy Duthu Rémy Duthu

Ship your Jest suite green.

Purpose-built for teams who take delivery speed and reliability seriously.