← Debugging Guides

阅读中文版本

Jest did not exit — Find and Fix Open Handles

Diagnose Jest hanging after tests by running detectOpenHandles, identifying servers, timers, and database clients left open, then closing them in afterAll hooks.

intermediatedebuggingjesttesting

Symptoms

  • Jest did not exit one second after the test run has completed
  • CI jobs hang after all tests pass until a timeout kills them
  • The process exits only when --forceExit is used
  • The warning lists open handles from TCP servers or timers

Possible causes

  • An HTTP server started in tests is never closed
  • Database, Redis, or message broker clients keep sockets open
  • setInterval or setTimeout timers are created and not cleared
  • A library starts a background worker or watcher during import

Diagnosis steps

Run with open handle detection

npx jest --detectOpenHandles --runInBand

Interpretation

The report lists each open handle with a stack trace showing where it was created. --runInBand avoids worker processes hiding the origin.

Identify the owning test file

npx jest --detectOpenHandles --runInBand 2>&1 | grep -B2 -A12 'Jest has detected the following' | head -80

Interpretation

The trace points to the module that created the handle. A server.close or client.disconnect missing in afterAll is the usual finding.

Confirm it is not just a slow test

npx jest --runInBand --verbose --testTimeout=10000 2>&1 | tail -30

Interpretation

If the suite reports all tests passed and then waits, it is a handle leak. If a test is still running, the timeout is the issue instead.

Use forceExit only to confirm the diagnosis

npx jest --forceExit --runInBand

Interpretation

A clean exit with --forceExit proves Jest is waiting on handles rather than doing work. It does not fix the leak.

Root cause

Jest waits for the Node event loop to drain before exiting. Any active TCP server, database connection, interval timer, or file watcher keeps the loop alive, so the run appears complete while the process never terminates. Libraries that create clients at import time can leak even when the test body looks clean.

Fix

Close every resource in afterAll or afterEach: await new Promise(resolve => server.close(resolve)), call client.disconnect() or pool.end(), clear timers, and stop workers. Create clients inside beforeAll rather than at module scope, and mock modules that open connections by default. Reserve --forceExit for a temporary CI unblock.

Risk note

ForceExit can truncate asynchronous cleanup and hide real failures, and it may drop coverage writes. Closing a shared connection too early breaks later tests. Fake timers change promise scheduling and can make unrelated tests flaky if not restored.

Verify the fix

Run the full suite with --detectOpenHandles and confirm no handles are reported, then run without --forceExit and verify the process exits with code 0 on its own. Repeat in CI with the same worker count used in production pipelines.

Passing tests, hanging process

Jest exits when the event loop is empty. A test suite can report every test passed and still hang because a server, socket, or interval is keeping Node alive:

Jest did not exit one second after the test run has completed.
This usually means that there are asynchronous operations that weren't stopped in your tests.

Locate the handle

npx jest --detectOpenHandles --runInBand

--runInBand keeps everything in one process so the stack trace points at the real creator. The report usually names a TCP server, a Timeout from setInterval, or a client from a database driver.

Close resources deterministically

let server;
let pool;

beforeAll(async () => {
  pool = await createPool();
  server = app.listen(0);
});

afterAll(async () => {
  await new Promise((resolve) => server.close(resolve));
  await pool.end();
});

Move client creation out of module scope, unref or clear timers, and mock modules that connect on import. If a dependency starts a background worker, call its shutdown method or isolate it behind a mock.

Verify

Run the suite without --forceExit and confirm the process returns to the shell with exit code 0. Then run with --detectOpenHandles and check the report is empty. Reproduce in CI with the same worker settings; a leak that only appears with several workers usually means a per-worker resource is not closed.