Not all asynchronous code uses promises — older APIs and some Node.js core functions rely on callbacks instead. This lesson covers Jest's done callback for testing this style of code.
The done Callback
When your test function accepts a single parameter (conventionally named done), Jest waits until that function is called before ending the test, instead of waiting for the test function itself to return. This is designed specifically for code that uses callbacks rather than promises.
If done() is never called, the test eventually times out and fails, which is Jest's way of catching a callback that never fired.
function fetchUserWithCallback(id, callback) {
setTimeout(() => {
callback(null, { id, name: 'Ada' });
}, 10);
}
test('calls back with user data', (done) => {
fetchUserWithCallback(1, (error, user) => {
expect(error).toBeNull();
expect(user).toEqual({ id: 1, name: 'Ada' });
done();
});
});
Jest waits for done() to be called, which only happens once the callback fires inside setTimeout.
Handling Errors with done()
test('calls back with an error for invalid input', (done) => {
fetchUserWithCallback(-1, (error, user) => {
try {
expect(error).toBeInstanceOf(Error);
done();
} catch (assertionError) {
done(assertionError); // fail the test with the assertion error
}
});
});
Calling done() with no arguments marks the test as passed (assuming no thrown errors before it).
Calling done(error) explicitly fails the test with that error.
Wrap assertions inside the callback in try/catch so a failed expect() doesn't just throw silently.
Without the try/catch, a failing assertion inside the callback can cause a confusing timeout instead of a clear failure.
Wrap the callback API in a Promise and use async/await
Why try/catch Matters Inside done() Callbacks
If an expect() assertion fails inside a callback without a surrounding try/catch, the thrown error happens inside an async callback that Jest isn't directly watching, which often surfaces as a confusing test timeout instead of a clear assertion failure. Wrapping assertions in try/catch and calling done(error) in the catch block produces a proper, readable failure message.
Preferring Promises Over done() When Possible
Because done() requires careful error handling, most teams prefer converting callback-based dependencies into promises (using util.promisify() in Node.js, or a manual wrapper) and testing with async/await instead, reserving done() for cases where that isn't practical.
const util = require('util');
const fetchUserAsync = util.promisify(fetchUserWithCallback);
test('resolves with user data (promisified)', async () => {
const user = await fetchUserAsync(1);
expect(user).toEqual({ id: 1, name: 'Ada' });
});
Common Mistakes
Forgetting to call done(), causing the test to time out instead of clearly failing or passing.
Letting a failed assertion inside the callback go unwrapped by try/catch, producing a confusing timeout.
Mixing done() with return somePromise or async/await in the same test.
Not considering util.promisify() when a callback-based dependency could be tested more simply as a promise.
Key Takeaways
The done parameter tells Jest to wait for an explicit call before ending the test.
Wrap assertions inside a callback in try/catch, calling done(error) on failure.
A test that never calls done() will time out and fail.
Promisifying callback-based APIs often simplifies testing compared to using done() directly.
Pro Tip
If you're testing a third-party callback-based library, check whether it (or Node's util.promisify) offers a promise-based variant first — you'll usually get a cleaner test by avoiding done() altogether.
You now know how to test callback-based code. Next, learn how to control time itself using Jest's fake timers.