Testing

Testing Async Code in Node.js

A practical guide to testing asynchronous code in Node.js covering callbacks, Promises, async/await, event emitters, streams, timers, and retry patterns with Jest and Mocha.

Testing Async Code in Node.js

Asynchronous code is where tests go wrong. A test that forgets to wait for a Promise passes silently — the assertion runs after the test finishes, and the failure is swallowed. A callback test that never calls done() hangs indefinitely. A timer test that uses real setTimeout is slow and flaky.

Every async pattern in Node.js requires a specific testing approach. This guide covers them all: callbacks, Promises, event emitters, streams, timers, and retry logic. Each section shows the wrong way, explains why it fails, and demonstrates the correct approach.

Prerequisites

  • Node.js installed (v16+)
  • Jest or Mocha configured
  • Understanding of callbacks, Promises, and async patterns

Testing Callbacks

The Wrong Way

// BAD — test always passes, assertion runs after test completes
test("fetches user data", function() {
  fetchUser(1, function(err, user) {
    expect(user.name).toBe("Alice"); // This might never run!
  });
  // Test ends here, before the callback fires
});

The Correct Way — Using done()

// GOOD — done() signals when the async operation completes
test("fetches user data", function(done) {
  fetchUser(1, function(err, user) {
    try {
      expect(err).toBeNull();
      expect(user.name).toBe("Alice");
      done();
    } catch (error) {
      done(error); // Pass assertion errors to done
    }
  });
});

Testing Callback Errors

test("handles errors in callbacks", function(done) {
  fetchUser(99999, function(err, user) {
    try {
      expect(err).toBeTruthy();
      expect(err.message).toBe("User not found");
      expect(user).toBeUndefined();
      done();
    } catch (error) {
      done(error);
    }
  });
});

Converting Callbacks to Promises for Testing

var util = require("util");
var fetchUserAsync = util.promisify(fetchUser);

test("fetches user data", function() {
  return fetchUserAsync(1).then(function(user) {
    expect(user.name).toBe("Alice");
  });
});

Testing Promises

Return the Promise

// BAD — forgetting to return the Promise
test("creates a user", function() {
  userService.create({ name: "Alice", email: "[email protected]" })
    .then(function(user) {
      expect(user.id).toBeDefined(); // May never run!
    });
  // Test passes immediately, Promise is not awaited
});

// GOOD — return the Promise chain
test("creates a user", function() {
  return userService.create({ name: "Alice", email: "[email protected]" })
    .then(function(user) {
      expect(user.id).toBeDefined();
      expect(user.name).toBe("Alice");
    });
});

Testing Promise Rejection

// GOOD — test that a Promise rejects
test("rejects invalid email", function() {
  return userService.create({ name: "Alice", email: "not-an-email" })
    .then(function() {
      throw new Error("Should have rejected");
    })
    .catch(function(err) {
      expect(err.message).toContain("Invalid email");
    });
});

// BETTER — Jest's built-in rejection testing
test("rejects invalid email", function() {
  return expect(
    userService.create({ name: "Alice", email: "not-an-email" })
  ).rejects.toThrow("Invalid email");
});

Testing Promise Chains

test("registers user, sends email, and returns profile", function() {
  return userService.register("Alice", "[email protected]", "password123")
    .then(function(result) {
      expect(result.user.id).toBeDefined();
      expect(result.emailSent).toBe(true);
      expect(result.user.password).toBeUndefined(); // Should not include password
    });
});

Testing Multiple Promises

test("processes multiple users concurrently", function() {
  var users = [
    { name: "Alice", email: "[email protected]" },
    { name: "Bob", email: "[email protected]" },
    { name: "Carol", email: "[email protected]" }
  ];

  var promises = users.map(function(u) {
    return userService.create(u);
  });

  return Promise.all(promises).then(function(results) {
    expect(results).toHaveLength(3);
    results.forEach(function(user) {
      expect(user.id).toBeDefined();
    });
  });
});

Testing Event Emitters

Basic Event Testing

var EventEmitter = require("events");

function createOrderProcessor() {
  var emitter = new EventEmitter();

  emitter.process = function(order) {
    emitter.emit("processing", order.id);

    setTimeout(function() {
      if (order.total <= 0) {
        emitter.emit("error", new Error("Invalid order total"));
        return;
      }

      emitter.emit("completed", {
        orderId: order.id,
        receipt: "REC-" + order.id,
        processedAt: new Date()
      });
    }, 100);
  };

  return emitter;
}

module.exports = createOrderProcessor;
var createOrderProcessor = require("./orderProcessor");

test("emits processing event", function(done) {
  var processor = createOrderProcessor();

  processor.on("processing", function(orderId) {
    expect(orderId).toBe(1);
    done();
  });

  processor.process({ id: 1, total: 100 });
});

test("emits completed event with receipt", function(done) {
  var processor = createOrderProcessor();

  processor.on("completed", function(result) {
    expect(result.orderId).toBe(1);
    expect(result.receipt).toBe("REC-1");
    expect(result.processedAt).toBeDefined();
    done();
  });

  processor.process({ id: 1, total: 100 });
});

test("emits error for invalid orders", function(done) {
  var processor = createOrderProcessor();

  processor.on("error", function(err) {
    expect(err.message).toBe("Invalid order total");
    done();
  });

  processor.process({ id: 2, total: -10 });
});

Testing Event Sequence

test("emits events in correct order", function(done) {
  var processor = createOrderProcessor();
  var events = [];

  processor.on("processing", function() { events.push("processing"); });
  processor.on("completed", function() {
    events.push("completed");
    expect(events).toEqual(["processing", "completed"]);
    done();
  });

  processor.process({ id: 1, total: 100 });
});

Wrapping Events in Promises

function waitForEvent(emitter, eventName, timeout) {
  return new Promise(function(resolve, reject) {
    var timer = setTimeout(function() {
      reject(new Error("Timeout waiting for event: " + eventName));
    }, timeout || 5000);

    emitter.once(eventName, function(data) {
      clearTimeout(timer);
      resolve(data);
    });

    emitter.once("error", function(err) {
      clearTimeout(timer);
      reject(err);
    });
  });
}

// Use in tests
test("completes order processing", function() {
  var processor = createOrderProcessor();
  var promise = waitForEvent(processor, "completed");

  processor.process({ id: 1, total: 100 });

  return promise.then(function(result) {
    expect(result.orderId).toBe(1);
  });
});

Testing Timers

The Problem with Real Timers

// BAD — test takes 5 seconds to run
function delayedGreeting(name, callback) {
  setTimeout(function() {
    callback("Hello, " + name);
  }, 5000);
}

test("greets after delay", function(done) {
  delayedGreeting("Alice", function(message) {
    expect(message).toBe("Hello, Alice");
    done();
  });
}); // Takes 5+ seconds!

Fake Timers with Jest

test("greets after delay", function() {
  jest.useFakeTimers();

  var callback = jest.fn();
  delayedGreeting("Alice", callback);

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(5000);

  expect(callback).toHaveBeenCalledWith("Hello, Alice");

  jest.useRealTimers();
});

Testing setInterval

// rateLimiter.js
function createRateLimiter(windowMs, maxRequests) {
  var requests = 0;

  setInterval(function() {
    requests = 0; // Reset counter
  }, windowMs);

  return {
    checkLimit: function() {
      requests++;
      return requests <= maxRequests;
    },
    getCount: function() {
      return requests;
    }
  };
}

module.exports = createRateLimiter;
var createRateLimiter = require("./rateLimiter");

describe("RateLimiter", function() {
  beforeEach(function() { jest.useFakeTimers(); });
  afterEach(function() { jest.useRealTimers(); });

  test("allows requests within limit", function() {
    var limiter = createRateLimiter(60000, 3);

    expect(limiter.checkLimit()).toBe(true);
    expect(limiter.checkLimit()).toBe(true);
    expect(limiter.checkLimit()).toBe(true);
  });

  test("blocks requests over limit", function() {
    var limiter = createRateLimiter(60000, 3);

    limiter.checkLimit();
    limiter.checkLimit();
    limiter.checkLimit();
    expect(limiter.checkLimit()).toBe(false);
  });

  test("resets after window", function() {
    var limiter = createRateLimiter(60000, 3);

    limiter.checkLimit();
    limiter.checkLimit();
    limiter.checkLimit();
    expect(limiter.checkLimit()).toBe(false);

    jest.advanceTimersByTime(60000);

    expect(limiter.checkLimit()).toBe(true);
    expect(limiter.getCount()).toBe(1);
  });
});

Testing Debounce

// debounce.js
function debounce(fn, delay) {
  var timer = null;

  return function() {
    var args = arguments;
    var context = this;

    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, args);
    }, delay);
  };
}

module.exports = debounce;
var debounce = require("./debounce");

describe("debounce", function() {
  beforeEach(function() { jest.useFakeTimers(); });
  afterEach(function() { jest.useRealTimers(); });

  test("calls function after delay", function() {
    var fn = jest.fn();
    var debounced = debounce(fn, 300);

    debounced("hello");
    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledWith("hello");
  });

  test("resets delay on subsequent calls", function() {
    var fn = jest.fn();
    var debounced = debounce(fn, 300);

    debounced("first");
    jest.advanceTimersByTime(200);
    debounced("second");
    jest.advanceTimersByTime(200);
    debounced("third");

    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith("third");
  });
});

Testing Retry Logic

// retry.js
function retry(fn, options) {
  var maxRetries = (options && options.maxRetries) || 3;
  var delay = (options && options.delay) || 1000;
  var backoff = (options && options.backoff) || 2;

  return new Promise(function(resolve, reject) {
    var attempt = 0;

    function tryOnce() {
      attempt++;
      fn().then(resolve).catch(function(err) {
        if (attempt >= maxRetries) {
          reject(err);
          return;
        }

        var waitTime = delay * Math.pow(backoff, attempt - 1);
        setTimeout(tryOnce, waitTime);
      });
    }

    tryOnce();
  });
}

module.exports = retry;
var retry = require("./retry");

describe("retry", function() {
  beforeEach(function() { jest.useFakeTimers(); });
  afterEach(function() { jest.useRealTimers(); });

  test("succeeds on first attempt", function() {
    var fn = jest.fn().mockResolvedValue("success");

    var promise = retry(fn, { maxRetries: 3, delay: 1000 });

    return promise.then(function(result) {
      expect(result).toBe("success");
      expect(fn).toHaveBeenCalledTimes(1);
    });
  });

  test("retries on failure and succeeds", function() {
    var fn = jest.fn()
      .mockRejectedValueOnce(new Error("fail 1"))
      .mockRejectedValueOnce(new Error("fail 2"))
      .mockResolvedValue("success");

    var promise = retry(fn, { maxRetries: 3, delay: 1000, backoff: 1 });

    // First call fails immediately
    return Promise.resolve()
      .then(function() { return jest.advanceTimersByTime(1000); })
      .then(function() { return jest.advanceTimersByTime(1000); })
      .then(function() { return promise; })
      .then(function(result) {
        expect(result).toBe("success");
        expect(fn).toHaveBeenCalledTimes(3);
      });
  });

  test("rejects after max retries", function() {
    var fn = jest.fn().mockRejectedValue(new Error("always fails"));

    var promise = retry(fn, { maxRetries: 3, delay: 100, backoff: 1 });

    jest.advanceTimersByTime(100);
    jest.advanceTimersByTime(100);

    return promise
      .then(function() { throw new Error("Should have rejected"); })
      .catch(function(err) {
        expect(err.message).toBe("always fails");
        expect(fn).toHaveBeenCalledTimes(3);
      });
  });
});

Testing Streams

var stream = require("stream");

function createTransformStream(transformFn) {
  return new stream.Transform({
    objectMode: true,
    transform: function(chunk, encoding, callback) {
      try {
        var result = transformFn(chunk);
        this.push(result);
        callback();
      } catch (err) {
        callback(err);
      }
    }
  });
}

module.exports = createTransformStream;
var stream = require("stream");
var createTransformStream = require("./transformStream");

test("transforms stream data", function(done) {
  var transform = createTransformStream(function(data) {
    return { name: data.name.toUpperCase(), id: data.id };
  });

  var results = [];

  var input = new stream.Readable({
    objectMode: true,
    read: function() {}
  });

  input
    .pipe(transform)
    .on("data", function(chunk) {
      results.push(chunk);
    })
    .on("end", function() {
      expect(results).toEqual([
        { name: "ALICE", id: 1 },
        { name: "BOB", id: 2 }
      ]);
      done();
    });

  input.push({ name: "Alice", id: 1 });
  input.push({ name: "Bob", id: 2 });
  input.push(null); // End the stream
});

test("handles transform errors", function(done) {
  var transform = createTransformStream(function(data) {
    if (!data.name) throw new Error("Name is required");
    return data;
  });

  var input = new stream.Readable({ objectMode: true, read: function() {} });

  input.pipe(transform).on("error", function(err) {
    expect(err.message).toBe("Name is required");
    done();
  });

  input.push({ id: 1 }); // Missing name
});

Testing Parallel Async Operations

test("processes items in parallel with concurrency limit", function() {
  var processed = [];

  function processItem(item) {
    return new Promise(function(resolve) {
      setTimeout(function() {
        processed.push(item);
        resolve(item * 2);
      }, 10);
    });
  }

  var items = [1, 2, 3, 4, 5];
  var promises = items.map(function(item) {
    return processItem(item);
  });

  return Promise.all(promises).then(function(results) {
    expect(results).toEqual([2, 4, 6, 8, 10]);
    expect(processed).toHaveLength(5);
  });
});

Common Issues and Troubleshooting

Test passes but should fail

The Promise or callback is not awaited — the test completes before the assertion runs:

Fix: Always return Promises from tests. Use done() for callback tests. If using Jest, enable the jest/no-test-return-statement ESLint rule.

Test hangs and times out

A Promise never resolves or rejects, or done() is never called:

Fix: Add timeout assertions. Ensure all code paths call done() or resolve/reject. Add .catch(done) to Promise chains in done-style tests. Set appropriate test timeouts.

Fake timers do not advance async operations

jest.advanceTimersByTime() only advances setTimeout/setInterval, not Promise microtasks:

Fix: Alternate between advancing timers and flushing Promises. Use jest.advanceTimersByTimeAsync() in newer versions of Jest. Or restructure code to separate timer-dependent logic from Promise logic.

Event listener memory leak warning

Too many listeners added without being removed:

Fix: Use emitter.once() instead of emitter.on() in tests. Remove listeners in afterEach. Set emitter.setMaxListeners(0) in tests if the warning is expected.

Race conditions in parallel async tests

Two tests modify the same state concurrently:

Fix: Run tests serially with --runInBand. Use separate data per test. Isolate test state in beforeEach.

Best Practices

  • Always return Promises from test functions. If a test involves async code, the test function must return a Promise. Otherwise Jest/Mocha cannot detect assertion failures.
  • Use fake timers for time-dependent code. Real timers make tests slow and flaky. Fake timers make them instant and deterministic.
  • Wrap event emitters in Promises for cleaner tests. A waitForEvent() helper turns event-based tests into straightforward Promise-based tests.
  • Test error paths, not just success paths. Async code fails in async ways. Test that errors propagate correctly through callbacks, Promises, and event emitters.
  • Set appropriate timeouts. The default Jest timeout (5 seconds) may not be enough for integration tests. Set per-test timeouts with jest.setTimeout().
  • Use done.fail() or try/catch in callback tests. Without error handling, a failing assertion in a callback is swallowed and the test passes or hangs.
  • Clean up async resources in afterEach. Close connections, clear intervals, remove event listeners, and cancel pending timers. Leaked resources cause flaky tests and memory warnings.
  • Prefer Promises over callbacks in test code. Even if the code under test uses callbacks, use util.promisify() to convert them. Promise-based tests are easier to read and harder to get wrong.

References

Powered by Contentful