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 withjest/valid-expectand prefer.resolves.asynccallbacks inforEach— the loop discards returned promises, so per-item assertions never settle. Mitigation: usefor...ofwithawait, orawait Promise.all(items.map(...)).- try/catch error tests with no assertion guard — a resolving call skips the
catchsilently. Mitigation: addexpect.assertions(1)or switch to.rejects.toThrow. - Fake timers without flushing microtasks —
advanceTimersByTimealone leaves the assertion pending. Mitigation:awaitthe promise after advancing, orawait 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().
Failures That Pass, and Passes That Prove Nothing #
The characteristic danger of an unawaited promise is not a failing test — it is a passing one. A test that returns before its assertions run reports success, and the assertion that would have failed executes later, throwing into an empty call stack where the runner may attribute it to a different test or discard it entirely.
That produces a specific and unpleasant pattern: a test suite that is green while the behaviour it claims to verify is broken, plus an occasional unrelated test failing for reasons that make no sense. The second symptom is often what leads people to investigate, and the trail goes to the wrong file.
Two guards catch the whole class cheaply. Requiring assertion counts makes a test that skipped its assertions fail rather than pass silently — an explicit expectation that a stated number of assertions ran. And failing the run on unhandled rejections turns a rejection that escaped into a build failure rather than a warning nobody reads.
// Prove the assertions actually ran, and refuse to ignore escaped rejections.
// Trade-off: an assertion count is one more line to maintain per test, and it is
// what turns a silently skipped assertion into an immediate failure.
test('loads the invoice', async () => {
expect.assertions(2);
const invoice = await loadInvoice('INV-1');
expect(invoice.id).toBe('INV-1');
expect(invoice.total).toBe(1250);
});
process.on('unhandledRejection', (reason) => { throw reason; });
Rejections, Resolutions and the Shapes That Slip Through #
Three assertion shapes account for most escaped promises, and each has a form that cannot silently pass.
Asserting on a rejection is the most error-prone: calling the function and expecting a throw without awaiting means the rejection happens after the test finishes. The reliable form awaits a rejection matcher, which fails if the promise resolves rather than rejecting — an important property, since a function that unexpectedly succeeds should fail the test, and a bare try/catch will not notice.
Asserting inside a callback — in a then, in an event handler, in a subscription — runs outside the test’s control flow entirely. If the callback never fires the assertion never runs, and the test passes. Converting the callback into an awaited promise, or asserting on the settled state afterwards, keeps the assertion inside the test.
Looping with async work silently drops promises when the loop body is asynchronous and nothing collects the results. The iteration finishes immediately, the test ends, and the work continues into the next test — where its failures are attributed to the wrong file.
The unifying rule is that every promise a test creates must be either awaited or explicitly collected before the test returns. A lint rule for floating promises enforces it mechanically, and it is one of the few rules in this area that catches the problem at authoring time rather than after a confusing debugging session.