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().