Real-Time Event Testing with Jest and Mocha: Async Patterns

Real-Time Event Testing with Jest and Mocha: Async Patterns

Testing real-time event-driven code is one of the more intellectually demanding corners of frontend and Node.js testing. The code is inherently asynchronous, order-dependent, and timing-sensitive. Standard expect(value).toBe(x) assertions are insufficient when the value hasn't arrived yet. This guide covers the patterns that actually work in production test suites — including the ones Jest's documentation glosses over.

The Core Challenge: Asserting on Future Events

The fundamental problem with event testing is that you're asserting on something that hasn't happened yet at assertion time. You have three tools for handling this:

  1. Promise-based collectors — wrap event reception in a Promise, await it
  2. Callback-based assertions — call done() from inside the event handler
  3. Polling — check a side-effectful accumulator until it matches your expectation

Each has its place. Promise-based collectors are the cleanest for single events. Polling is best for "eventually consistent" scenarios. Callback-based (done) is legacy but still appears in Mocha codebases.

EventEmitter Testing Fundamentals

Node.js's EventEmitter is the foundation of most real-time patterns. Test it correctly and the patterns transfer directly to streams, Socket.io, and custom event buses.

const { EventEmitter } = require('events');

class UserService extends EventEmitter {
  constructor(db) {
    super();
    this.db = db;
  }

  async createUser(data) {
    const user = await this.db.insert(data);
    this.emit('user:created', user);
    return user;
  }

  async deleteUser(id) {
    const user = await this.db.findById(id);
    if (!user) throw new Error('User not found');
    await this.db.delete(id);
    this.emit('user:deleted', { id, email: user.email });
  }
}

Testing with Jest + async/await:

describe('UserService events', () => {
  let service, mockDb;

  beforeEach(() => {
    mockDb = {
      insert: jest.fn(),
      findById: jest.fn(),
      delete: jest.fn(),
    };
    service = new UserService(mockDb);
  });

  test('emits user:created after successful insert', async () => {
    const newUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
    mockDb.insert.mockResolvedValue(newUser);

    const eventPromise = new Promise((resolve) => {
      service.once('user:created', resolve);
    });

    await service.createUser({ name: 'Alice', email: 'alice@example.com' });
    const emittedUser = await eventPromise;

    expect(emittedUser).toEqual(newUser);
  });

  test('emits user:deleted with user details', async () => {
    mockDb.findById.mockResolvedValue({ id: '1', email: 'alice@example.com' });
    mockDb.delete.mockResolvedValue();

    const deletedEvent = new Promise((resolve) => {
      service.once('user:deleted', resolve);
    });

    await service.deleteUser('1');
    const payload = await deletedEvent;

    expect(payload).toEqual({ id: '1', email: 'alice@example.com' });
  });

  test('does not emit user:deleted when user not found', async () => {
    mockDb.findById.mockResolvedValue(null);

    let eventFired = false;
    service.once('user:deleted', () => { eventFired = true; });

    await expect(service.deleteUser('nonexistent')).rejects.toThrow('User not found');
    expect(eventFired).toBe(false);
  });
});

Note the once vs on distinction. In tests, always use once unless you explicitly need to capture multiple events — on listeners survive between tests if you forget cleanup and can cause cross-test contamination.

Collecting Multiple Events

When you need to assert on a sequence of events, use an accumulator pattern:

function collectEvents(emitter, eventName, count, timeout = 1000) {
  return new Promise((resolve, reject) => {
    const collected = [];
    const timer = setTimeout(() => {
      emitter.removeListener(eventName, handler);
      reject(new Error(
        `Timeout: expected ${count} '${eventName}' events, got ${collected.length}. ` +
        `Collected: ${JSON.stringify(collected)}`
      ));
    }, timeout);

    function handler(data) {
      collected.push(data);
      if (collected.length === count) {
        clearTimeout(timer);
        emitter.removeListener(eventName, handler);
        resolve(collected);
      }
    }

    emitter.on(eventName, handler);
  });
}

describe('Event sequences', () => {
  test('emits three price updates in order', async () => {
    const feed = new EventEmitter();
    const prices = [100, 102, 98];

    // Simulate async price updates
    setTimeout(() => feed.emit('price', 100), 10);
    setTimeout(() => feed.emit('price', 102), 20);
    setTimeout(() => feed.emit('price', 98), 30);

    const updates = await collectEvents(feed, 'price', 3);

    expect(updates).toEqual([100, 102, 98]);
  });
});

The timeout in collectEvents is crucial. Without it, a test that never emits enough events will hang until Jest's global timeout fires, giving you a useless error message.

Testing with Jest's fakeTimers

jest.useFakeTimers() is essential for testing debounce, throttle, and any time-dependent logic without making your tests slow.

function debounce(fn, wait) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, wait);
  };
}

function throttle(fn, limit) {
  let lastCall = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      return fn.apply(this, args);
    }
  };
}

describe('Debounce with fakeTimers', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('only fires once after repeated calls', () => {
    const handler = jest.fn();
    const debounced = debounce(handler, 200);

    debounced('a');
    debounced('b');
    debounced('c');

    // Not fired yet
    expect(handler).not.toHaveBeenCalled();

    jest.advanceTimersByTime(200);

    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler).toHaveBeenCalledWith('c'); // Last call wins
  });

  test('fires again if called after wait period', () => {
    const handler = jest.fn();
    const debounced = debounce(handler, 200);

    debounced('first');
    jest.advanceTimersByTime(200);
    expect(handler).toHaveBeenCalledTimes(1);

    debounced('second');
    jest.advanceTimersByTime(200);
    expect(handler).toHaveBeenCalledTimes(2);
    expect(handler).toHaveBeenLastCalledWith('second');
  });

  test('resets timer on each call within window', () => {
    const handler = jest.fn();
    const debounced = debounce(handler, 200);

    debounced('a');
    jest.advanceTimersByTime(100);
    debounced('b'); // Reset timer
    jest.advanceTimersByTime(100);
    expect(handler).not.toHaveBeenCalled(); // 200ms hasn't passed since 'b'

    jest.advanceTimersByTime(100);
    expect(handler).toHaveBeenCalledTimes(1);
  });
});

describe('Throttle with fakeTimers', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('limits calls to one per period', () => {
    const handler = jest.fn();
    const throttled = throttle(handler, 100);

    throttled('a');
    throttled('b');
    throttled('c');

    expect(handler).toHaveBeenCalledTimes(1); // Only first call fires
    expect(handler).toHaveBeenCalledWith('a');

    jest.advanceTimersByTime(100);
    throttled('d');
    expect(handler).toHaveBeenCalledTimes(2);
    expect(handler).toHaveBeenLastCalledWith('d');
  });
});

Testing Pub/Sub Patterns

Real-time applications often have a pub/sub layer sitting between producers and consumers. Here's how to test it thoroughly:

class PubSub {
  constructor() {
    this.subscribers = new Map();
    this.messageLog = [];
  }

  subscribe(channel, handler) {
    if (!this.subscribers.has(channel)) {
      this.subscribers.set(channel, new Set());
    }
    this.subscribers.get(channel).add(handler);

    return () => this.unsubscribe(channel, handler); // Return unsubscribe function
  }

  unsubscribe(channel, handler) {
    this.subscribers.get(channel)?.delete(handler);
  }

  publish(channel, message) {
    this.messageLog.push({ channel, message, timestamp: Date.now() });
    const handlers = this.subscribers.get(channel) || new Set();
    handlers.forEach((handler) => {
      try {
        handler(message);
      } catch (err) {
        this.emit?.('error', err); // Don't crash on handler error
      }
    });
    return handlers.size; // Return delivery count
  }

  getHistory(channel) {
    return this.messageLog.filter((m) => m.channel === channel);
  }
}

describe('PubSub', () => {
  let pubsub;

  beforeEach(() => {
    pubsub = new PubSub();
  });

  test('delivers message to all subscribers', () => {
    const handler1 = jest.fn();
    const handler2 = jest.fn();

    pubsub.subscribe('events', handler1);
    pubsub.subscribe('events', handler2);

    pubsub.publish('events', { type: 'click' });

    expect(handler1).toHaveBeenCalledWith({ type: 'click' });
    expect(handler2).toHaveBeenCalledWith({ type: 'click' });
  });

  test('does not deliver to different channel', () => {
    const handler = jest.fn();
    pubsub.subscribe('channelA', handler);

    pubsub.publish('channelB', 'message');

    expect(handler).not.toHaveBeenCalled();
  });

  test('unsubscribe prevents future delivery', () => {
    const handler = jest.fn();
    const unsub = pubsub.subscribe('events', handler);

    pubsub.publish('events', 'first');
    unsub();
    pubsub.publish('events', 'second');

    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler).toHaveBeenCalledWith('first');
  });

  test('returns delivery count from publish', () => {
    pubsub.subscribe('events', () => {});
    pubsub.subscribe('events', () => {});

    expect(pubsub.publish('events', 'msg')).toBe(2);
    expect(pubsub.publish('empty-channel', 'msg')).toBe(0);
  });

  test('subscriber error does not prevent other subscribers from receiving', () => {
    const badHandler = jest.fn().mockImplementation(() => {
      throw new Error('handler crashed');
    });
    const goodHandler = jest.fn();

    pubsub.subscribe('events', badHandler);
    pubsub.subscribe('events', goodHandler);

    expect(() => pubsub.publish('events', 'msg')).not.toThrow();
    expect(goodHandler).toHaveBeenCalled();
  });

  test('maintains message history per channel', () => {
    pubsub.publish('orders', { id: 1 });
    pubsub.publish('orders', { id: 2 });
    pubsub.publish('users', { id: 99 });

    const orderHistory = pubsub.getHistory('orders');
    expect(orderHistory).toHaveLength(2);
    expect(orderHistory[0].message).toEqual({ id: 1 });
  });
});

Detecting Race Conditions

Race conditions in event-driven code are notoriously hard to catch. The pattern: two operations that must not interleave — but your code doesn't prevent it.

class EventQueue {
  constructor() {
    this.processing = false;
    this.queue = [];
    this.emitter = new EventEmitter();
  }

  async process(event) {
    if (this.processing) {
      this.queue.push(event);
      return;
    }
    this.processing = true;
    try {
      await this._handle(event);
      // Process queued events
      while (this.queue.length > 0) {
        await this._handle(this.queue.shift());
      }
    } finally {
      this.processing = false;
    }
  }

  async _handle(event) {
    // Simulate async work
    await new Promise((res) => setImmediate(res));
    this.emitter.emit('processed', event);
  }
}

describe('Race condition in EventQueue', () => {
  test('processes events sequentially, not concurrently', async () => {
    const queue = new EventQueue();
    const processedEvents = [];

    queue.emitter.on('processed', (e) => processedEvents.push(e));

    // Fire multiple events simultaneously
    await Promise.all([
      queue.process({ id: 1 }),
      queue.process({ id: 2 }),
      queue.process({ id: 3 }),
    ]);

    // All events should be processed
    expect(processedEvents).toHaveLength(3);
    // IDs should appear exactly once each
    expect(processedEvents.map((e) => e.id).sort()).toEqual([1, 2, 3]);
  });

  test('concurrent calls do not corrupt state', async () => {
    const results = [];
    let concurrentlyProcessing = 0;
    let maxConcurrency = 0;

    const queue = new EventQueue();
    const originalHandle = queue._handle.bind(queue);
    queue._handle = async (event) => {
      concurrentlyProcessing++;
      maxConcurrency = Math.max(maxConcurrency, concurrentlyProcessing);
      await originalHandle(event);
      concurrentlyProcessing--;
      results.push(event.id);
    };

    await Promise.all([1, 2, 3, 4, 5].map((id) => queue.process({ id })));

    expect(maxConcurrency).toBe(1); // Never more than 1 concurrent
    expect(results).toHaveLength(5);
  });
});

Mocha Patterns for Event Testing

Mocha's done callback is still widely used in older codebases. The patterns translate directly:

// Mocha style
describe('EventEmitter (Mocha)', function() {
  it('emits data event', function(done) {
    const emitter = new EventEmitter();

    emitter.once('data', (value) => {
      try {
        assert.strictEqual(value, 42);
        done();
      } catch (err) {
        done(err); // Pass assertion errors to Mocha
      }
    });

    setTimeout(() => emitter.emit('data', 42), 10);
  });

  // Cleaner: return a promise (works in Mocha 3+)
  it('emits data event (promise style)', function() {
    const emitter = new EventEmitter();

    const received = new Promise((resolve) => {
      emitter.once('data', resolve);
    });

    setTimeout(() => emitter.emit('data', 42), 10);

    return received.then((value) => {
      assert.strictEqual(value, 42);
    });
  });
});

Testing Event Listeners for Memory Leaks

A common production issue: components subscribe to events but never unsubscribe. Node.js warns when an emitter has more than 10 listeners — write a test for it.

describe('Listener cleanup', () => {
  test('component removes listener on destroy', () => {
    const bus = new EventEmitter();

    class Component {
      constructor(bus) {
        this.bus = bus;
        this.handleUpdate = this.handleUpdate.bind(this);
        bus.on('update', this.handleUpdate);
      }

      handleUpdate(data) {
        this.lastUpdate = data;
      }

      destroy() {
        this.bus.removeListener('update', this.handleUpdate);
      }
    }

    const components = Array.from({ length: 5 }, () => new Component(bus));

    expect(bus.listenerCount('update')).toBe(5);

    components.forEach((c) => c.destroy());

    expect(bus.listenerCount('update')).toBe(0);
  });

  test('emitter does not exceed MaxListeners', () => {
    const bus = new EventEmitter();
    bus.setMaxListeners(20);

    for (let i = 0; i < 15; i++) {
      bus.on('event', () => {});
    }

    // Should not trigger MaxListenersExceededWarning
    expect(bus.listenerCount('event')).toBe(15);
    expect(bus.listenerCount('event')).toBeLessThanOrEqual(bus.getMaxListeners());
  });
});

Testing real-time event code well means accepting that time is a variable in your tests — and controlling it explicitly through fake timers, promise barriers, and event accumulators rather than arbitrary setTimeout delays. The patterns above give you the control you need to write deterministic tests for inherently asynchronous code.

Read more

Start now free