DEV Community

Cover image for Why Jest Tests Pass Locally but Fail in CI (and How to Fix It)
Stephan Dum
Stephan Dum

Posted on

Why Jest Tests Pass Locally but Fail in CI (and How to Fix It)

✅ All tests pass locally,
💥 but CI fails.
🔄 You rerun CI.
❓ It passes.

If this sounds familiar, you’re probably dealing with async leaks in Jest tests.

This article explains why this happens, why it’s usually caused by async leaks, and how to make failures deterministic instead of random.

TL;DR

If Jest tests pass locally but fail in CI:

  • Async work is leaking between tests
  • Failures hit random tests
  • Fix by enforcing cleanup at test boundaries
  • Tools like jest-doctor make leaks deterministic

The Typical CI Mystery

A common failure looks like:

Expected console.log not to be called
Received: "unexpected error"
Enter fullscreen mode Exit fullscreen mode

But:

  • The failing test never calls console.log.
  • Running that test alone passes.
  • Running the whole suite sometimes fails.

Or tests that:

  • fail only in CI,
  • fail only when run together,
  • fail only occasionally.

These failures are random. They’re usually caused by async work leaking between tests.

What Is an Async Leak?

An async leak happens when a test starts async work but doesn’t clean it up.

Examples:

  • A timer still running
  • A promise that resolves later
  • An event listener still attached
  • Background work logging after the test ends
const doSomething = jest.fn();

it('will run but never stop', async () => {
  setInterval(() => { doSomething() } , 100);
  await waitFor(() => expect(doSomething).toHaveBeenCalled());
});
Enter fullscreen mode Exit fullscreen mode

The test passes.

But the interval is still pending which can interfer with other tests.

Why It Fails in CI but Not Locally

CI environments behave differently:

Slower machines: Timers and async work resolve later, exposing leaks.

Parallel execution: Concurrency scheduling differs between environments.

CPU contention: Background async tasks run at unpredictable times.

Locally, timing hides the issue. CI exposes it.

Why Jest’s Built-in --detectOpenHandles Often Misses It

  • runs only at process shutdown,
  • doesn’t tie leaks to specific tests,
  • misses subtle async patterns.

Attempt to collect and print open handles preventing Jest from exiting cleanly. Use this in cases where you need to use --forceExit in order for Jest to exit to potentially track down the reason. This implies --runInBand, making tests run serially. Implemented using async_hooks... This option has a significant performance penalty and should only be used for debugging.

Jest does not enforce async test isolation. Nothing in Jest guarantees that:

  • All async work triggered by the test has completed
  • All timers have been cleared
  • All async side effects have stopped

The Real Root Cause

Most flaky failures come from tests that violate this rule:

Every test must clean up all async work it starts.

Common culprits:

  • Forgotten await
  • Timers not cleared
  • Event listeners not removed
  • Promises resolving after test end
  • Console output after completion
// promise.test.js
const doSomething = jest.fn().mockImplementation(() => 
  new Promise((resolve) => {
    setTimeout(resolve, 100);
  })
);

const mutateGlobalState = async () => {
  localStorage.setItem('status', 'pending');
  await doSomething();
  localStorage.setItem('status', 'done');
};

beforeEach(() => {
  localStorage.removeItem('status');
});

it('should have awaited the promise', () => {
  // ups forgot to await the promise
  mutateGlobalState();
  expect(localStorage.getItem('status')).toEqual('pending');
});
Enter fullscreen mode Exit fullscreen mode

The mutation finishes later and can interfere with other tests.

Making Failures Deterministic

Instead of letting leaks break random tests, fail the test that caused the leak.

Required steps:

  1. Track async resources during the test.
  2. Check cleanup at test boundaries.
  3. Fail immediately if something remains.

The error becomes:

3 open Promise(s) found!
  at (promise.test.js:8:27)
Enter fullscreen mode Exit fullscreen mode

Now the problem is obvious, reproducible and actionable.

Using jest-doctor to Catch Async Leaks

To solve this problem systematically, you can use jest-doctor, a custom Jest environment that detects async leaks at test boundaries and fails the leaking test deterministically.

It tracks:

  • unresolved promises
  • real and fake timers
  • window DOM event listeners
  • unexpected console or process output

Quick setup

npm install --save-dev jest-doctor
Enter fullscreen mode Exit fullscreen mode

Update your Jest config:

export default {
  // or jest-doctor/env/node
  testEnvironment: 'jest-doctor/env/jsdom',
  // optional
  reporters: ['default', 'jest-doctor/reporter'],
};
Enter fullscreen mode Exit fullscreen mode

Now tests fail where the leak happens, not somewhere else.

test failing because of open promise

Practical Advice to Reduce Flakes

Good practices include:

  • Always await async work
  • Use Typescript with ESLint rules to detect floating promises
  • Avoid real timers
  • Prefer fake timers
  • Clean up listeners
  • Don’t ignore console output

Why Test Isolation Matters

Without isolation:

  • failures appear randomly
  • debugging wastes hours
  • CI becomes unreliable
  • developers stop trusting tests

With isolation:

  • failures are reproducible
  • bugs are easier to fix
  • CI becomes stable
  • teams move faster

Closing Thoughts

Most flaky Jest tests are caused by async work escaping test boundaries.

The fix isn’t retrying CI jobs — it’s enforcing cleanup and making failures deterministic.

Once leaks fail the test that created them, flakes disappear, debugging becomes straightforward, and CI becomes trustworthy again.


If you’ve been fighting CI flakes, what turned out to be the root cause in your project?

Top comments (0)