QualityPilot
5 min read

11 patterns that make your Jest tests flaky (with examples)

A field guide to the eleven recurring patterns that turn green tests red on Tuesday at 4 PM. Examples in Jest + Vitest, with the deterministic fix for each.

jestvitestflaky-teststestingjavascript

If you've ever stared at a CI run that passed locally and failed on main with no code change, you've met a flaky test. They have personalities. Most of them fall into eleven recurring patterns — and once you can name the pattern, the fix is usually obvious.

This is the field guide. Each pattern: one short example showing the smell, then the deterministic version. All examples are Jest / Vitest because that's the most common stack we see; the same patterns translate to Playwright, Cypress and pytest with minor syntax changes.

(If you'd like to skip the reading and just paste a test file at a free analyzer, /tools/jest-flaky-detector runs the same heuristic over your code and gives you a 0–100 risk score — no signup. Otherwise, read on.)

1. Fixed sleep / timeout

The classic. "I added setTimeout(resolve, 1000) because the redirect needed time to land."

// Flaky
test("login redirects", async () => {
  render(<App />);
  fireEvent.click(screen.getByText("Sign in"));
  await new Promise((r) => setTimeout(r, 1000));
  expect(window.location.pathname).toBe("/dashboard");
});

// Deterministic
test("login redirects", async () => {
  render(<App />);
  fireEvent.click(screen.getByText("Sign in"));
  await waitFor(() => expect(window.location.pathname).toBe("/dashboard"));
});

waitFor polls the assertion until it passes (default ~1s, configurable). The test now succeeds the moment the redirect lands AND fails on real failure. Hard waits guarantee neither.

2. Real network call without a mock

Your test passes against https://api.github.com today. Tomorrow GitHub returns a 503 for 12 seconds and CI lights up red.

// Flaky
test("fetches user", async () => {
  const user = await getUser(1);
  expect(user.name).toBe("Octocat");
});

// Deterministic
test("fetches user", async () => {
  vi.spyOn(global, "fetch").mockResolvedValue(
    new Response(JSON.stringify({ name: "Octocat" }))
  );
  const user = await getUser(1);
  expect(user.name).toBe("Octocat");
});

If the test is exercising the network call (an integration test by intent), pin it to a recorded fixture (MSW, Polly, nock). If the test is exercising your consumer of the network call, mock the call.

3. Race condition between async setup and assert

Both the setup and the assert are async. Whichever finishes first wins the heat. Sometimes that's the assertion.

// Flaky
test("loads from cache", async () => {
  cache.set("key", "value");          // sync
  fetchValue();                        // async, fire-and-forget
  expect(receivedValue).toBe("value"); // checks too soon
});

// Deterministic
test("loads from cache", async () => {
  cache.set("key", "value");
  await fetchValue();                  // await it
  expect(receivedValue).toBe("value");
});

Rule of thumb: if a function returns a Promise, await it. The lint rule @typescript-eslint/no-floating-promises catches this category at write-time and is worth turning on.

4. Time-dependent code without a fake clock

Date.now(), new Date(), performance.now() — all of them produce a different value each test run. Tests that assert on derived timestamps drift over hours, days, leap seconds.

// Flaky
test("token expiry", () => {
  const token = mintToken({ ttlSec: 60 });
  expect(token.expiresAt).toBe(Date.now() + 60_000); // off-by-1ms half the time
});

// Deterministic
test("token expiry", () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
  const token = mintToken({ ttlSec: 60 });
  expect(token.expiresAt).toBe(Date.parse("2026-01-01T00:01:00Z"));
  vi.useRealTimers();
});

Vitest ships fake-timers; Jest has them too. Pin time at the start of every test that touches it.

5. Random / pseudo-random input

Math.random(), crypto.randomUUID(), faker.name.firstName() — all produce different values each run. If your assertion includes the random value, the test will flake the moment you change something downstream.

// Flaky
test("generates user id", () => {
  const u = createUser();
  expect(u.id.length).toBe(36); // depends on UUID format never changing
  expect(u.id).toMatch(/^[a-f0-9-]{36}$/);
});

// Deterministic
test("generates user id", () => {
  vi.spyOn(crypto, "randomUUID").mockReturnValue("00000000-0000-0000-0000-000000000001");
  const u = createUser();
  expect(u.id).toBe("00000000-0000-0000-0000-000000000001");
});

Mock the random source. If the test is about randomness (e.g. distribution), use a seeded RNG.

6. Console.error suppressed

Your test passes, but the actual production code is throwing a warning every render. The CI output is clean because someone added console.error = vi.fn() in the setup file three months ago.

This isn't flaky per se but it's a flake breeding ground — masked errors hide actual bugs that cause flakes.

// Smell
beforeAll(() => { console.error = vi.fn(); });

// Better
beforeAll(() => {
  const original = console.error;
  console.error = (...args) => {
    if (typeof args[0] === "string" && args[0].includes("act(...)")) return;
    original(...args); // surface everything else
  };
});

Suppress specifically the warnings you've decided to live with. Never blanket-mute.

7. Shared mutable state across tests

Test A mutates a module-level singleton. Test B reads the singleton, expects empty, gets test A's leftover.

// Flaky
const cache = new Map<string, unknown>();   // module scope

test("a", () => {
  cache.set("x", 1);
  expect(cache.get("x")).toBe(1);
});

test("b", () => {
  expect(cache.size).toBe(0); // FAILS when run after "a"
});

// Deterministic
beforeEach(() => cache.clear());            // OR move cache into test scope

Worse: the tests pass when run individually (vitest --grep "b") but fail in suite. Worst kind of flake — order-dependent and you'll waste two days on it.

8. Reliance on test execution order

Same root cause as #7 but a step further: the test author knows about the dependency and writes "this test must run after the seed test". Vitest, Jest, Playwright all reserve the right to reorder, parallelize, or shard arbitrarily.

// Flaky
test("seeds the user", () => { db.insert(USER); });
test("reads the user", () => { expect(db.find(USER.id)).toBe(USER); });

// Deterministic
describe("user", () => {
  beforeEach(() => db.insert(USER));
  afterEach(() => db.clear());
  test("reads the user", () => { expect(db.find(USER.id)).toBe(USER); });
});

Each test prepares its own world.

9. External services in CI (real database, real S3, real Stripe)

The DB you spin up in CI is almost identical to prod. Almost. Connection pool size, default isolation, CIDR rules, version. Tests that talk to it pass 999 times and fail the 1000th.

The fix is split: integration tests live in their own job with explicit retry budget and known-acceptable flakiness. Unit tests have zero external services. If you're mixing them, you've got a portfolio risk.

10. Browser timing in JSDom (Vitest / Jest browser-like envs)

JSDom doesn't have a real layout engine. getBoundingClientRect returns zeros. IntersectionObserver doesn't fire. Animations are a no-op.

If your test asserts on layout-derived behavior, it's flaky in JSDom because the non-flake state is also wrong — you're just lucky the wrongness happens to match the assertion.

// Will pass in JSDom but for the wrong reason; flakes when JSDom updates
test("scrolls element into view", async () => {
  const { container } = render(<List />);
  expect(container.scrollTop).toBe(0); // scrollTop is always 0 in JSDom
});

// Better
import { it } from "vitest";
it.skip.if(typeof window !== "undefined" && !("ResizeObserver" in window))(
  "scrolls element into view",
  () => { /* real assertion in a real browser env */ }
);

For real layout behavior, use Playwright or Cypress with a real browser. Don't lie to yourself with JSDom.

11. Concurrent access to a shared filesystem path

Two parallel test files write to /tmp/test-fixture.json. One reads while the other writes. Welcome to race conditions in 2026.

// Flaky
test("writes config", () => {
  fs.writeFileSync("/tmp/cfg.json", JSON.stringify({ a: 1 }));
  expect(JSON.parse(fs.readFileSync("/tmp/cfg.json", "utf8"))).toEqual({ a: 1 });
});

// Deterministic
test("writes config", () => {
  const path = `/tmp/cfg-${expect.getState().currentTestName}-${Math.random()}.json`;
  fs.writeFileSync(path, JSON.stringify({ a: 1 }));
  expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual({ a: 1 });
  fs.unlinkSync(path);
});

Better: use tmp.dirSync() or os.tmpdir() + a unique subdir per test. Best: don't touch the filesystem at all if you can mock it.

How to use this list

You can grep your test suite for these patterns by hand — setTimeout, fetch(, Math.random, etc. It works but it's tedious.

Or paste a test file into /tools/jest-flaky-detector. It runs the same 11-pattern detector over your code in <2 seconds and tells you which patterns matched + a 0–100 risk score. Free, no signup. The analyzer is the same one we use inside QualityPilot to score every test in a repo.

If you want a more thorough pass — scan an entire repo at once + see grade A–F + per-file breakdowns — paste the GitHub URL at qlens.dev/scan/owner/repo. Also free, also no signup.

If you have a 12th pattern that bites you regularly and isn't here, reply to support@qlens.dev — we add to the detector pretty regularly.


ShareTwitterLinkedIn

About QualityPilot

QualityPilot watches your CI for failed tests and proposes a fix as a GitHub PR. You merge or you don't — no auto-merge, no fluff. See how it works.

Related posts