#Testing async code
A test runner can only judge what it waits for. Jest calls your test function and then decides the test is over at one of three moments: when the function returns (a plain synchronous test), when the promise it returned settles, or when you call the done callback. Nothing else. If your assertion runs 50 ms later, inside a .then that nobody returned, Jest has already moved on.
A good mental model is a referee with a whistle. The moment your test function hands control back, the referee blows the whistle — unless you hand them a promise, which means "wait for this before you score it". Anything that happens after the whistle isn't scored against your test. As you'll see below, it can even be scored against the next test.
So every async test has one job beyond its assertions: make Jest wait. async/await is the clearest way; returning the promise and done are the older ways you'll still meet.
// asyncFunction.js
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => resolve('data received'), 100);
});
}
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) reject(new Error('invalid id'));
else resolve({ id, name: 'Rohit' });
}, 50);
});
}
function fetchDataCallback(cb) {
setTimeout(() => cb(null, 'data received'), 100);
}
module.exports = { fetchData, fetchUser, fetchDataCallback };From my notes, the five ways to test it (the first is the one from the doc):
// asyncFunction.test.js
const { fetchData, fetchUser, fetchDataCallback } = require('./asyncFunction');
test('async/await: waits for the promise', async () => {
const data = await fetchData();
expect(data).toBe('data received');
});
test('return the promise: Jest waits for it', () => {
return fetchData().then((data) => {
expect(data).toBe('data received');
});
});
test('.resolves / .rejects matchers', async () => {
await expect(fetchUser(7)).resolves.toEqual({ id: 7, name: 'Rohit' });
await expect(fetchUser(0)).rejects.toThrow('invalid id');
});
test('try/catch for a rejection, with expect.assertions', async () => {
expect.assertions(1);
try {
await fetchUser(-1);
} catch (err) {
expect(err.message).toBe('invalid id');
}
});
test('done callback for callback-style APIs', (done) => {
fetchDataCallback((err, data) => {
try {
expect(err).toBeNull();
expect(data).toBe('data received');
done();
} catch (e) {
done(e);
}
});
});$ npx jest asyncFunction.test.js --verbose
PASS ./asyncFunction.test.js
✓ async/await: waits for the promise (103 ms)
✓ return the promise: Jest waits for it (103 ms)
✓ .resolves / .rejects matchers (106 ms)
✓ try/catch for a rejection, with expect.assertions (51 ms)
✓ done callback for callback-style APIs (102 ms)
Tests: 5 passed, 5 total- async/await: an
asyncfunction always returns a promise, so Jest gets one and waits.await fetchData()pauses the test for the real 100 ms timer, thendatais'data received'andtoBecompares withObject.is. The(103 ms)is the real timer — these tests don't fake time (see Fake timers). - Return the promise: the function isn't
async, but it returns the.then(...)chain. Jest waits for that chain, so theexpectinside.thenruns before the whistle. Drop thereturnand the test would pass instantly without ever checking anything. .resolves/.rejects:expect(promise).resolves.toEqual(x)itself returns a promise that settles once the original promise does. That's why each line needsawait.fetchUser(7)resolves to{ id: 7, name: 'Rohit' }(toEqualcompares structure, not identity);fetchUser(0)rejects, and.rejects.toThrow('invalid id')checks the rejection's message contains that text.- try/catch:
await fetchUser(-1)turns the rejection into a thrown error, socatchruns and asserts on it.expect.assertions(1)is the safety net: it says "exactly one assertion must run in this test". IffetchUser(-1)had resolved,catchwould be skipped and, without that line, the test would pass with zero assertions. done: for callback APIs. Jest sees the test function declares a parameter and waits untildone()is called. Thetry/catchmatters: if anexpectthrows inside the callback, the error is thrown on a timer, not in the test, so you must hand it todone(e)yourself — otherwise Jest just seesdonenever called and reports a timeout instead of the real failure.- All five pass in about 0.6 s. Which to use:
async/awaitfor anything promise-based,.rejectsfor "this should fail", anddoneonly when the code under test takes a callback and you can't promisify it.
Now the mistakes. Each of these is a real test I ran; the results are the interesting part.
// broken.test.js — the mistakes
const { fetchUser } = require('./asyncFunction');
test('forgot to await .rejects', () => {
expect(fetchUser(5)).rejects.toThrow('invalid id'); // no await, no return
});
test('forgot to return the .then chain', () => {
fetchUser(5).then((user) => {
expect(user.name).toBe('Someone else');
});
});
test('expect.assertions catches the silent pass', async () => {
expect.assertions(1);
try {
await fetchUser(5); // resolves, so catch never runs
} catch (err) {
expect(err.message).toBe('invalid id');
}
});$ npx jest broken.test.js -t "forgot to await"
PASS ./broken.test.js
✓ forgot to await .rejects
○ skipped forgot to return the .then chain
○ skipped expect.assertions catches the silent pass
Tests: 2 skipped, 1 passed, 3 total
…
JestAssertionError: expect(received).rejects.toThrow()
Received promise resolved instead of rejected
Resolved to value: {"id": 5, "name": "Rohit"}
Node.js v26.7.0 ← process exits with code 1
$ npx jest broken.test.js -t "expect.assertions catches"
● expect.assertions catches the silent pass
Expected one assertion to be called but received zero assertion calls.- Forgot to await
.rejects: the test body returnsundefinedsynchronously, so Jest marks it ✓ passed immediately. 50 ms laterfetchUser(5)resolves, the.rejectsmatcher fails, and its promise rejects with nobody listening. Run alone, Jest prints "PASS" and then the process dies with that unhandled rejection (exit code 1). The bug is reported — but after a green tick, which is easy to miss in CI logs. - Forgot to return the
.thenchain: same shape. The test passes at once; thetoBe('Someone else')failure happens later, outside any test. expect.assertions(1)catches the third mistake:fetchUser(5)resolves,catchnever runs, zero assertions happen, and Jest fails the test with "Expected one assertion to be called but received zero assertion calls".- Now the nasty part. Run all three together and Jest 30 reports tests 1 and 2 as failed — but test 3 passes. Why? Tests 1 and 2 finish synchronously, so test 3 starts straight away and awaits for 50 ms. During those 50 ms, test 2's stray
.thenruns itsexpect(...). That assertion is counted toward whichever test is running — test 3 — which satisfies itsexpect.assertions(1). I confirmed it: run tests 1 and 3 only and test 3 fails as it should; run tests 2 and 3 and test 3 passes. - The lesson: an un-awaited assertion doesn't just fail to protect its own test, it can corrupt the result of a neighbouring one. Await or return every promise; the ESLint rules
jest/valid-expectand@typescript-eslint/no-floating-promisescatch most of these (see Floating promises and unhandled rejections).
done and promisestest('rejects bad ids', async () => {
try {
await fetchUser(3);
} catch (err) {
expect(err.message).toMatch(/invalid/);
}
});Show answer
It passes — and it shouldn't.
fetchUser(3)resolves to{ id: 3, name: 'Rohit' }after 50 ms because3 > 0.awaitgets the value, no error is thrown, so thecatchblock never runs.- The test function's promise resolves with zero assertions made. Jest has no rule that a test must assert something, so it's green.
- Fix: add
expect.assertions(1)at the top, or better, writeawait expect(fetchUser(3)).rejects.toThrow(/invalid/)— which fails with "Received promise resolved instead of rejected", exactly what you want to know.