Article · Root Causes of JavaScript Test Flakiness

Handling Unresolved Promise Assertions in Jest

A Jest test can report green while never actually checking anything: if a promise-based assertion is built but never awaited, the test function returns before the assertion settles, and the matcher result is discarded. This guide shows how these silent passes arise out of Async State Management in E2E Tests, how to surface them, and how to make every promise assertion block the test until it resolves or rejects.

10 sections URL: /root-causes-of-javascript-test-flakiness/async-state-management-in-e2e-tests/handling-unresolved-promise-assertions-in-jest/
Awaited versus orphaned promise assertion timelines Two timelines: an awaited assertion settles before the test ends and is reported, while an orphaned assertion settles after the test already passed and its result is lost. Awaited assertion await expect settles test ends reported Orphaned assertion expect (no await) test ends false green settles late result lost A promise assertion only protects the test if it settles before the test function returns.
An awaited assertion blocks the test; an orphaned one settles after the pass is already recorded.

Root cause #

Jest decides a test is finished when the test function returns (for synchronous bodies) or when the promise it returns resolves (for async bodies). Any promise you create but neither await nor return runs on the microtask queue after that decision. If the matcher inside it eventually throws, there is no longer a test to attach the failure to, so the rejection is dropped or surfaces as an unhandled rejection in an unrelated test. The result is a test that is structurally incapable of failing — the worst kind of flakiness, because it hides real regressions instead of reporting intermittent ones.

The two common shapes are an async assertion helper called without await (expect(promise).resolves.toBe(x) returns a promise), and an async callback passed to a utility that ignores its return value. Both compile, lint clean under default rules, and pass forever. Because the failure mode is absence of checking rather than a thrown error, it behaves differently from the timing races covered in Async State Management in E2E Tests and the broader Root Causes of JavaScript Test Flakiness: there is no stack trace to chase, only a coverage gap.

Step-by-step fix #

1. Always await or return .resolves / .rejects #

The .resolves and .rejects modifiers return a promise. Forgetting to settle it is the single most common cause of a silent pass.

// FAIL-SAFE: await forces the matcher to run before the test ends.
test('user load resolves', async () => {
  // Trade-off: await adds latency only equal to the real promise — no masking,
  // because the test now genuinely blocks on the assertion settling.
  await expect(loadUser(1)).resolves.toMatchObject({ id: 1 });
  await expect(loadUser(-1)).rejects.toThrow('not found');
});

If you prefer a single assertion per test, return expect(...).resolves... works identically — Jest waits on the returned promise.

2. Assert the call count to detect assertions that never ran #

expect.assertions(n) and expect.hasAssertions() make Jest fail when the expected number of matchers did not execute — turning a silent skip into a hard failure.

test('rejects on bad input', async () => {
  // Trade-off: a tiny maintenance cost (update n when you add asserts),
  // bought against the much larger risk of a test that checks nothing.
  expect.assertions(1);
  await expect(parsePayload('{bad')).rejects.toThrow(SyntaxError);
});

For try/catch error tests this is essential: if the awaited call unexpectedly resolves, the catch block never runs, and without expect.assertions the test passes despite missing the error entirely.

3. Drive timer-dependent promises with fake timers deterministically #

When a promise resolves on a setTimeout or debounce, real timers make the test slow and order-dependent. Fake timers let you advance time explicitly, then flush microtasks.

test('debounced save resolves after the window', async () => {
  jest.useFakeTimers();
  const saved = saveAfterDebounce(payload); // resolves on a 300ms timer
  // Trade-off: advancing timers is deterministic and fast, but you MUST still
  // await the promise — advancing the clock does not flush the microtask queue.
  jest.advanceTimersByTime(300);
  await expect(saved).resolves.toBe('ok');
  jest.useRealTimers();
});

A frequent trap: advancing fake timers schedules the resolution but the .then callbacks run as microtasks. You still need the await (or await Promise.resolve()) to let them flush before asserting.

4. Make orphaned promises fail the suite via lint and unhandled-rejection detection #

Catch the pattern statically instead of hoping a reviewer spots the missing await.

// jest.config.js — surface late rejections instead of swallowing them.
module.exports = {
  // Trade-off: errorOnDeprecated/strict modes add minor CI noise, but a real
  // unhandled rejection from a late assertion now fails the run loudly.
  testEnvironment: 'node',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

In jest.setup.js, escalate unhandled rejections to failures, and enable the ESLint rule @typescript-eslint/no-floating-promises (or jest/valid-expect-in-promise) so a .resolves chain without await fails CI before merge.

Pitfalls #

  • expect(promise).toBe(value) without a modifier — compares the promise object, not its value, and passes meaninglessly. Mitigation: lint with jest/valid-expect and prefer .resolves.
  • async callbacks in forEach — the loop discards returned promises, so per-item assertions never settle. Mitigation: use for...of with await, or await Promise.all(items.map(...)).
  • try/catch error tests with no assertion guard — a resolving call skips the catch silently. Mitigation: add expect.assertions(1) or switch to .rejects.toThrow.
  • Fake timers without flushing microtasksadvanceTimersByTime alone leaves the assertion pending. Mitigation: await the promise after advancing, or await jest.runOnlyPendingTimersAsync().
  • Relying on the test passing as proof it works — a silent pass looks identical to a real pass. Mitigation: temporarily break the implementation and confirm the test goes red.

Reliability targets #

Target Goal
Orphaned-promise assertions in suite 0 (enforced by lint in CI)
Tests using expect.assertions/hasAssertions for async error paths 100%
Unhandled-rejection events per CI run 0 (fail the build on any)
False-pass detection (mutation-test survival on async tests) < 5% surviving mutants
CI pass-rate stability after fix > 99% week-over-week

Frequently Asked Questions #

Q: Why does my test pass even though the assertion is clearly wrong? A: The matcher is built inside a promise you never awaited. Jest ends the test before the promise settles, so the failing matcher throws into a dead test and is discarded. Add await (or return) to the .resolves/.rejects chain, and add expect.assertions(n) to prove the matcher actually ran.

Q: Does expect.assertions slow tests down? A: No measurably. It only records a counter and compares it when the test ends. The cost is negligible against the value of failing loudly when an await is missing or a catch block is skipped.

Q: Fake timers resolve my promise but the assertion still sees pending state. Why? A: Advancing fake timers schedules the resolution, but the .then continuations run as microtasks that flush only when you yield. Keep the await on the promise after advanceTimersByTime, or use the async timer helpers like jest.runOnlyPendingTimersAsync().