Skip to content

Testing Callbacks in Jest

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.

Testing Callbacks Cheat Sheet

The essentials of Jest's done callback pattern.

Situation Pattern
Basic async callback test test('...', (done) => { fn(() => { ...; done(); }); })
Failing assertion safety Wrap assertions in try { ... } catch (e) { done(e); }
Callback never fires Test times out and fails automatically
Promisify instead 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.