Real-Time Event Testing with Socket.io

Real-Time Event Testing with Socket.io

Socket.io wraps raw WebSocket (and falls back to HTTP long-polling) with a structured event system: rooms, namespaces, acknowledgements, and automatic reconnection. Each of these abstractions adds testable behavior that raw WebSocket testing doesn't cover.

This guide focuses on what makes Socket.io testing distinct from basic WebSocket testing: event-driven assertions, room membership verification, acknowledgement flows, and namespace isolation.

Test Infrastructure Setup

Socket.io testing requires a real server running, not mocks. The Socket.io protocol has enough state — session IDs, room memberships, connection upgrades — that unit testing with mocks gives false confidence.

Server Setup for Tests

// test/helpers/socket-server.js
const { createServer } = require('http');
const { Server } = require('socket.io');
const { io: Client } = require('socket.io-client');

function createTestServer(serverLogic) {
  const httpServer = createServer();
  const io = new Server(httpServer, {
    cors: { origin: '*' },
    transports: ['websocket'], // Force WebSocket, skip polling in tests
  });
  
  serverLogic(io);
  
  return new Promise((resolve) => {
    httpServer.listen(0, () => { // Port 0 = random available port
      const port = httpServer.address().port;
      resolve({ io, httpServer, port });
    });
  });
}

function createTestClient(port, options = {}) {
  return Client(`http://localhost:${port}`, {
    transports: ['websocket'],
    forceNew: true,
    ...options,
  });
}

module.exports = { createTestServer, createTestClient };

Using forceNew: true ensures each test gets a fresh client connection, not a reused one from Socket.io's internal pool. Using transports: ['websocket'] skips the HTTP polling upgrade negotiation, making tests faster and more predictable.

Test Lifecycle

const { createTestServer, createTestClient } = require('./helpers/socket-server');

describe('Socket.io events', () => {
  let io, httpServer, port, client;
  
  beforeAll(async () => {
    ({ io, httpServer, port } = await createTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('chat:message', (msg) => {
          io.emit('chat:message', { ...msg, id: generateId() });
        });
      });
    }));
  });
  
  afterAll(() => {
    httpServer.close();
    io.close();
  });
  
  beforeEach((done) => {
    client = createTestClient(port);
    client.on('connect', done);
  });
  
  afterEach(() => {
    client.disconnect();
  });
});

Event Emission Testing

Basic Event Assert

test('server broadcasts message to all clients', (done) => {
  const receiver = createTestClient(port);
  
  receiver.on('connect', () => {
    client.emit('chat:message', { text: 'hello' });
    
    receiver.on('chat:message', (msg) => {
      expect(msg.text).toBe('hello');
      expect(msg.id).toBeDefined();
      receiver.disconnect();
      done();
    });
  });
});

Multiple Event Assertions

When testing sequences of events, use a counter pattern:

test('typing indicator events fire in correct order', (done) => {
  const events = [];
  
  client.on('user:typing', (data) => events.push({ type: 'typing', ...data }));
  client.on('user:stopped-typing', (data) => events.push({ type: 'stopped', ...data }));
  
  const sender = createTestClient(port);
  sender.on('connect', async () => {
    sender.emit('typing:start');
    await sleep(100);
    sender.emit('typing:stop');
    
    await waitFor(() => events.length >= 2, 3000);
    
    expect(events[0].type).toBe('typing');
    expect(events[1].type).toBe('stopped');
    sender.disconnect();
    done();
  });
});

Acknowledgement Testing

Acknowledgements are Socket.io's callback mechanism for confirming delivery. They're commonly used for operations that need a success/failure response without setting up a full request-response event pair.

describe('Socket.io acknowledgements', () => {
  test('join room acknowledges with success', (done) => {
    client.emit('room:join', { roomId: 'test-room' }, (response) => {
      expect(response.success).toBe(true);
      expect(response.roomId).toBe('test-room');
      done();
    });
  });
  
  test('join nonexistent room acknowledges with error', (done) => {
    client.emit('room:join', { roomId: 'nonexistent-999' }, (response) => {
      expect(response.success).toBe(false);
      expect(response.error).toBe('ROOM_NOT_FOUND');
      done();
    });
  });
  
  test('acknowledgement times out when server stalls', (done) => {
    // Create server that never calls the ack callback
    const slowServer = createTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('slow:action', (data, ack) => {
          // Intentionally never calls ack()
        });
      });
    });
    
    const timeoutClient = createTestClient(slowServer.port);
    const start = Date.now();
    
    // Client-side timeout wrapper
    function emitWithTimeout(event, data, timeout) {
      return new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error('Ack timeout')), timeout);
        timeoutClient.emit(event, data, (response) => {
          clearTimeout(timer);
          resolve(response);
        });
      });
    }
    
    emitWithTimeout('slow:action', {}, 1000)
      .catch((err) => {
        expect(err.message).toBe('Ack timeout');
        expect(Date.now() - start).toBeGreaterThanOrEqual(1000);
        done();
      });
  });
});

Room Testing

Rooms are one of Socket.io's most used features and one of the most undertested. Room isolation bugs — messages leaking between rooms, users receiving events from rooms they left — are common and serious.

describe('Socket.io rooms', () => {
  test('clients in different rooms do not receive each other messages', async () => {
    const clientA = createTestClient(port);
    const clientB = createTestClient(port);
    
    await Promise.all([waitForConnect(clientA), waitForConnect(clientB)]);
    
    const messagesA = [];
    const messagesB = [];
    
    clientA.on('room:message', (msg) => messagesA.push(msg));
    clientB.on('room:message', (msg) => messagesB.push(msg));
    
    await ackEmit(clientA, 'room:join', { roomId: 'room-alpha' });
    await ackEmit(clientB, 'room:join', { roomId: 'room-beta' });
    
    clientA.emit('room:message', { text: 'message for alpha only' });
    
    await sleep(200); // Give time for message to propagate
    
    expect(messagesA).toHaveLength(1);
    expect(messagesB).toHaveLength(0); // Should NOT receive room-alpha messages
    
    clientA.disconnect();
    clientB.disconnect();
  });
  
  test('client stops receiving messages after leaving room', async () => {
    const messages = [];
    client.on('room:message', (msg) => messages.push(msg));
    
    await ackEmit(client, 'room:join', { roomId: 'test-room' });
    
    // Should receive while in room
    io.to('test-room').emit('room:message', { text: 'message 1' });
    await sleep(100);
    expect(messages).toHaveLength(1);
    
    await ackEmit(client, 'room:leave', { roomId: 'test-room' });
    
    // Should NOT receive after leaving
    io.to('test-room').emit('room:message', { text: 'message 2' });
    await sleep(100);
    expect(messages).toHaveLength(1); // Still 1, not 2
  });
  
  test('room membership persists across reconnection', async () => {
    await ackEmit(client, 'room:join', { roomId: 'persistent-room' });
    
    client.disconnect();
    await sleep(100);
    client.connect();
    await waitForConnect(client);
    
    // With persistent sessions, should be re-added to room on reconnect
    const messages = [];
    client.on('room:message', (msg) => messages.push(msg));
    
    io.to('persistent-room').emit('room:message', { text: 'after reconnect' });
    await sleep(100);
    
    // This test verifies your reconnection + room restoration behavior
    // Pass or fail depends on your server's design
    console.log('Room membership after reconnect:', messages.length > 0);
  });
});

Namespace Testing

Namespaces partition a Socket.io server into isolated channels with separate event handlers and middleware.

describe('Socket.io namespaces', () => {
  let io, httpServer, port;
  
  beforeAll(async () => {
    ({ io, httpServer, port } = await createTestServer((io) => {
      // Main namespace
      io.on('connection', (socket) => {
        socket.on('ping', (cb) => cb({ ns: '/' }));
      });
      
      // Admin namespace
      const admin = io.of('/admin');
      admin.use((socket, next) => {
        if (socket.handshake.auth.role !== 'admin') {
          return next(new Error('UNAUTHORIZED'));
        }
        next();
      });
      admin.on('connection', (socket) => {
        socket.on('ping', (cb) => cb({ ns: '/admin' }));
      });
    }));
  });
  
  test('clients connect to different namespaces independently', (done) => {
    const mainClient = createTestClient(port); // connects to /
    const adminClient = createTestClient(port, {
      path: '/socket.io',
      auth: { role: 'admin' },
      // Connect to /admin namespace
    });
    
    const adminNsClient = io_client(`http://localhost:${port}/admin`, {
      auth: { role: 'admin' },
      transports: ['websocket'],
      forceNew: true,
    });
    
    let responses = 0;
    
    mainClient.emit('ping', (res) => {
      expect(res.ns).toBe('/');
      responses++;
      if (responses === 2) done();
    });
    
    adminNsClient.emit('ping', (res) => {
      expect(res.ns).toBe('/admin');
      responses++;
      if (responses === 2) done();
    });
  });
  
  test('admin namespace rejects unauthorized clients', (done) => {
    const unauthorized = io_client(`http://localhost:${port}/admin`, {
      auth: { role: 'user' }, // Not admin
      transports: ['websocket'],
      forceNew: true,
    });
    
    unauthorized.on('connect_error', (err) => {
      expect(err.message).toBe('UNAUTHORIZED');
      done();
    });
  });
});

Middleware Testing

Socket.io middleware runs before connection is accepted. Test it separately from your event handlers:

describe('Socket.io middleware', () => {
  test('auth middleware rejects invalid tokens', (done) => {
    const server = createTestServer((io) => {
      io.use((socket, next) => {
        const token = socket.handshake.auth.token;
        if (!isValidToken(token)) {
          return next(new Error('invalid token'));
        }
        socket.user = decodeToken(token);
        next();
      });
    });
    
    const client = createTestClient(server.port, {
      auth: { token: 'invalid-token-xyz' },
    });
    
    client.on('connect_error', (err) => {
      expect(err.message).toBe('invalid token');
      done();
    });
  });
  
  test('middleware attaches decoded user to socket', (done) => {
    const server = createTestServer((io) => {
      io.use((socket, next) => {
        socket.user = { id: '123', name: 'Test User' };
        next();
      });
      
      io.on('connection', (socket) => {
        socket.emit('session', { userId: socket.user.id });
      });
    });
    
    const client = createTestClient(server.port);
    
    client.on('session', (data) => {
      expect(data.userId).toBe('123');
      done();
    });
  });
});

Disconnection and Cleanup Testing

Disconnection bugs are silent — connections drop and resources leak without obvious errors. Test cleanup explicitly:

describe('disconnection handling', () => {
  test('server removes socket from rooms on disconnect', async () => {
    await ackEmit(client, 'room:join', { roomId: 'cleanup-test' });
    
    const roomSizeBefore = io.sockets.adapter.rooms.get('cleanup-test')?.size ?? 0;
    expect(roomSizeBefore).toBe(1);
    
    client.disconnect();
    await sleep(100);
    
    const roomSizeAfter = io.sockets.adapter.rooms.get('cleanup-test')?.size ?? 0;
    expect(roomSizeAfter).toBe(0);
  });
  
  test('disconnect event fires with reason', (done) => {
    const reasons = [];
    
    // Server-side listener
    io.on('connection', (socket) => {
      socket.on('disconnect', (reason) => {
        reasons.push(reason);
      });
    });
    
    const testClient = createTestClient(port);
    testClient.on('connect', () => {
      testClient.disconnect(); // Client-initiated disconnect
    });
    
    setTimeout(() => {
      expect(reasons).toContain('client namespace disconnect');
      done();
    }, 500);
  });
  
  test('server-initiated disconnect reaches client', (done) => {
    io.once('connection', (socket) => {
      socket.disconnect(true); // Force disconnect
    });
    
    const testClient = createTestClient(port);
    
    testClient.on('disconnect', (reason) => {
      expect(reason).toBe('io server disconnect');
      done();
    });
  });
});

Testing with HelpMeTest

Socket.io endpoints need continuous monitoring — not just unit tests. Write a health check that validates real connection, subscription, and message delivery:

*** Test Cases ***
Socket.io Real-Time Health Check
    [Documentation]    Verify Socket.io endpoint accepts connections and delivers events
    ${client}=    Connect Socket.io    http://api.yourapp.com    auth=${AUTH_TOKEN}
    Socket.io Should Be Connected    ${client}
    Emit Event    ${client}    room:join    {"roomId": "monitoring"}
    ${ack}=    Wait For Acknowledgement    timeout=3s
    Should Be True    ${ack}[success]
    Close Socket.io    ${client}

Run this every 2 minutes. Socket.io deployments can break in ways that don't affect HTTP: the upgrade from polling to WebSocket can fail silently, or room state can become corrupted after a server restart. Continuous testing catches these before users do.

What to Test

Every Socket.io application needs tests for:

  1. Connection: successful connect, auth rejection, namespace rejection
  2. Events: each event type your client emits; each event type your server emits
  3. Acknowledgements: success paths, error paths, timeout behavior
  4. Rooms: join, leave, isolation between rooms, cleanup on disconnect
  5. Namespaces: correct routing, middleware enforcement per namespace
  6. Disconnection: cleanup of rooms, event firing, reason codes
  7. Reconnection: does room membership restore? Does queued state replay?

Socket.io's abstractions hide complexity — rooms, namespaces, and acknowledgements are all stateful. The state is where the bugs live.

Read more

Start now free