Socket.io Testing: Unit Tests with Jest, Integration with Supertest

Socket.io Testing: Unit Tests with Jest, Integration with Supertest

Socket.io applications require testing at three layers: the HTTP upgrade handshake, the event protocol layer (emit/on, ACKs, rooms), and real concurrency behavior under load. This guide covers in-process test setup, Jest event testing, middleware mocking, and scalable load patterns.

Socket.io is the de-facto standard for bidirectional real-time communication in Node.js applications. It layers its own protocol over WebSocket (with HTTP long-polling fallback), adds rooms, namespaces, and acknowledgements, and handles reconnection automatically. All of these features need testing — and the good news is that Socket.io's architecture makes in-process testing fast and straightforward.

In-Process Server Setup for Unit Tests

The fastest way to test Socket.io is to create a real server and client in the same process, without binding to a network port. This keeps tests isolated and milliseconds-fast:

// test/setup.ts
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { io as SocketIOClient, Socket as ClientSocket } from 'socket.io-client';
import { AddressInfo } from 'net';

export interface TestContext {
  io: SocketIOServer;
  clientSocket: ClientSocket;
  cleanup: () => Promise<void>;
}

export async function createTestServer(
  setupHandlers: (io: SocketIOServer) => void
): Promise<TestContext> {
  const httpServer = createServer();
  const io = new SocketIOServer(httpServer, {
    cors: { origin: '*' },
  });

  setupHandlers(io);

  await new Promise<void>(resolve => httpServer.listen(0, resolve));
  const { port } = httpServer.address() as AddressInfo;

  const clientSocket = SocketIOClient(`http://localhost:${port}`, {
    autoConnect: false,
    transports: ['websocket'],
  });

  const cleanup = () =>
    new Promise<void>(resolve => {
      clientSocket.disconnect();
      io.close(() => {
        httpServer.close(() => resolve());
      });
    });

  await new Promise<void>((resolve, reject) => {
    clientSocket.connect();
    clientSocket.on('connect', resolve);
    clientSocket.on('connect_error', reject);
  });

  return { io, clientSocket, cleanup };
}

Testing Basic Event Emit and Receive

With the setup above, writing event tests is straightforward:

// test/events.test.ts
import { createTestServer } from './setup';

describe('chat events', () => {
  it('server echoes message back to sender', async () => {
    const { io, clientSocket, cleanup } = await createTestServer(io => {
      io.on('connection', socket => {
        socket.on('chat:message', (msg: string, ack) => {
          socket.emit('chat:message', `echo: ${msg}`);
          if (ack) ack({ received: true });
        });
      });
    });

    const received = await new Promise<string>(resolve => {
      clientSocket.on('chat:message', resolve);
      clientSocket.emit('chat:message', 'hello');
    });

    expect(received).toBe('echo: hello');
    await cleanup();
  });

  it('server broadcasts to all connected clients', async () => {
    const { io, cleanup } = await createTestServer(io => {
      io.on('connection', socket => {
        socket.on('broadcast:trigger', () => {
          io.emit('broadcast:message', 'hello everyone');
        });
      });
    });

    // Connect two additional clients
    const port = (io.httpServer.address() as any).port;
    const [client1, client2] = await Promise.all([
      connectClient(port),
      connectClient(port),
    ]);

    const [msg1, msg2] = await Promise.all([
      new Promise<string>(res => client1.on('broadcast:message', res)),
      new Promise<string>(res => client2.on('broadcast:message', res)),
      new Promise<void>(res => setTimeout(() => {
        client1.emit('broadcast:trigger');
        res();
      }, 100)),
    ]);

    expect(msg1).toBe('hello everyone');
    expect(msg2).toBe('hello everyone');

    client1.disconnect();
    client2.disconnect();
    await cleanup();
  });
});

Testing Acknowledgements (ACKs)

Socket.io acknowledgements are callback-based confirmations that a message was received and processed. Test both the happy path and timeout behavior:

it('client receives ACK with server-generated ID', async () => {
  const { io, clientSocket, cleanup } = await createTestServer(io => {
    io.on('connection', socket => {
      socket.on('item:create', (data: { name: string }, ack) => {
        const id = `item-${Date.now()}`;
        // Simulate DB save, then ack
        ack({ success: true, id });
      });
    });
  });

  const result = await new Promise<{ success: boolean; id: string }>(resolve => {
    clientSocket.emit('item:create', { name: 'widget' }, resolve);
  });

  expect(result.success).toBe(true);
  expect(result.id).toMatch(/^item-\d+$/);
  await cleanup();
});

it('handles ACK timeout gracefully', async () => {
  const { clientSocket, cleanup } = await createTestServer(io => {
    io.on('connection', socket => {
      socket.on('slow:operation', () => {
        // Deliberately never calls ack — simulates server hang
      });
    });
  });

  const result = await new Promise<{ error: string } | null>(resolve => {
    const timeout = setTimeout(() => resolve({ error: 'timeout' }), 2000);
    clientSocket.emit('slow:operation', {}, (data: unknown) => {
      clearTimeout(timeout);
      resolve(data as null);
    });
  });

  expect(result).toEqual({ error: 'timeout' });
  await cleanup();
});

Room and Namespace Isolation Testing

Rooms are one of Socket.io's most powerful features. Test that events are scoped correctly and don't leak across rooms:

it('room events do not leak to other rooms', async () => {
  const { io, cleanup } = await createTestServer(io => {
    io.on('connection', socket => {
      socket.on('join:room', (room: string) => socket.join(room));
      socket.on('room:message', ({ room, msg }: { room: string; msg: string }) => {
        io.to(room).emit('room:message', msg);
      });
    });
  });

  const port = (io.httpServer.address() as any).port;
  const [room1Client, room2Client] = await Promise.all([
    connectClient(port),
    connectClient(port),
  ]);

  // Join different rooms
  await Promise.all([
    emitWithAck(room1Client, 'join:room', 'room-A'),
    emitWithAck(room2Client, 'join:room', 'room-B'),
  ]);

  const room2Messages: string[] = [];
  room2Client.on('room:message', (msg: string) => room2Messages.push(msg));

  // Send to room-A only
  room1Client.emit('room:message', { room: 'room-A', msg: 'private to room A' });
  await new Promise(res => setTimeout(res, 200));

  expect(room2Messages).toHaveLength(0); // room-B should receive nothing

  room1Client.disconnect();
  room2Client.disconnect();
  await cleanup();
});

For namespace isolation:

it('namespaces are isolated from each other', async () => {
  const { io, cleanup } = await createTestServer(io => {
    io.of('/admin').on('connection', socket => {
      socket.on('admin:command', (cmd: string) => {
        io.of('/admin').emit('admin:result', `executed: ${cmd}`);
      });
    });

    io.on('connection', socket => {
      // Public namespace — should never receive admin events
    });
  });

  const port = (io.httpServer.address() as any).port;
  const adminClient = connectClientToNamespace(port, '/admin');
  const publicClient = connectClient(port);

  const publicMessages: string[] = [];
  publicClient.on('admin:result', (msg: string) => publicMessages.push(msg));

  await new Promise<void>(res => {
    adminClient.on('connect', res);
    adminClient.connect();
  });

  adminClient.emit('admin:command', 'restart');
  await new Promise(res => setTimeout(res, 200));

  expect(publicMessages).toHaveLength(0);

  adminClient.disconnect();
  publicClient.disconnect();
  await cleanup();
});

Middleware and Authentication Testing

Socket.io middleware runs before the connection is established. Test that your auth middleware correctly rejects unauthenticated connections:

// auth middleware
function authMiddleware(socket: Socket, next: (err?: Error) => void) {
  const token = socket.handshake.auth.token;
  if (!token || token !== 'valid-token') {
    return next(new Error('Authentication failed'));
  }
  socket.data.userId = 'user-123';
  next();
}

// test
it('rejects connections without valid token', async () => {
  const { cleanup } = await createTestServer(io => {
    io.use(authMiddleware);
    io.on('connection', socket => {
      socket.emit('welcome', socket.data.userId);
    });
  });

  const port = (io.httpServer.address() as any).port;
  const badClient = SocketIOClient(`http://localhost:${port}`, {
    transports: ['websocket'],
    auth: { token: 'wrong-token' },
  });

  const error = await new Promise<string>(resolve => {
    badClient.on('connect_error', err => resolve(err.message));
    badClient.connect();
  });

  expect(error).toBe('Authentication failed');
  badClient.disconnect();
  await cleanup();
});

it('accepts connections with valid token and sets userId', async () => {
  const { cleanup } = await createTestServer(io => {
    io.use(authMiddleware);
    io.on('connection', socket => {
      socket.emit('welcome', socket.data.userId);
    });
  });

  const port = (io.httpServer.address() as any).port;
  const goodClient = SocketIOClient(`http://localhost:${port}`, {
    transports: ['websocket'],
    auth: { token: 'valid-token' },
  });

  const userId = await new Promise<string>(resolve => {
    goodClient.on('welcome', resolve);
    goodClient.connect();
  });

  expect(userId).toBe('user-123');
  goodClient.disconnect();
  await cleanup();
});

Supertest for the HTTP Handshake

The Socket.io HTTP upgrade path can be tested with Supertest to verify your server responds correctly to WebSocket upgrade requests:

import request from 'supertest';
import { createServer } from 'http';
import { Server } from 'socket.io';

it('responds to Socket.io polling endpoint', async () => {
  const httpServer = createServer();
  const io = new Server(httpServer);
  io.on('connection', () => {});

  httpServer.listen(0);

  const res = await request(httpServer)
    .get('/socket.io/?EIO=4&transport=polling')
    .expect(200);

  expect(res.text).toMatch(/^0\{/); // Socket.io open packet
  io.close();
});

Load Testing with Multiple Client Workers

For concurrent load testing, spawn multiple Socket.io clients and measure message latency and delivery guarantees:

// load-test.ts
import { io as SocketIOClient } from 'socket.io-client';

interface LoadTestResult {
  connected: number;
  messagesReceived: number;
  avgLatencyMs: number;
  errors: number;
}

async function runLoadTest(
  url: string,
  clientCount: number,
  messagesPerClient: number
): Promise<LoadTestResult> {
  const results = { connected: 0, messagesReceived: 0, latencies: [] as number[], errors: 0 };

  const clients = Array.from({ length: clientCount }, () =>
    SocketIOClient(url, { transports: ['websocket'], autoConnect: false })
  );

  await Promise.all(
    clients.map(client =>
      new Promise<void>((resolve, reject) => {
        client.on('connect', () => {
          results.connected++;
          resolve();
        });
        client.on('connect_error', err => {
          results.errors++;
          reject(err);
        });
        client.connect();
      }).catch(() => {})
    )
  );

  await Promise.all(
    clients.map(client =>
      new Promise<void>(resolve => {
        let received = 0;
        client.on('pong', (sentAt: number) => {
          results.latencies.push(Date.now() - sentAt);
          results.messagesReceived++;
          received++;
          if (received >= messagesPerClient) resolve();
        });

        for (let i = 0; i < messagesPerClient; i++) {
          client.emit('ping', Date.now());
        }
      })
    )
  );

  clients.forEach(c => c.disconnect());

  return {
    connected: results.connected,
    messagesReceived: results.messagesReceived,
    avgLatencyMs:
      results.latencies.reduce((a, b) => a + b, 0) / results.latencies.length,
    errors: results.errors,
  };
}

it('handles 100 concurrent clients with sub-50ms avg latency', async () => {
  const result = await runLoadTest('http://localhost:3000', 100, 10);

  expect(result.connected).toBe(100);
  expect(result.errors).toBe(0);
  expect(result.messagesReceived).toBe(1000);
  expect(result.avgLatencyMs).toBeLessThan(50);
}, 30000);

What to Assert in Every Socket.io Test

Each Socket.io test should verify at least one of: correct event name emitted (typos in event names are a common bug), correct payload shape and content, correct scope (only intended recipients receive the event), ACK called with correct data, middleware behavior under valid and invalid credentials, and error handling when the server is overwhelmed or the client disconnects mid-operation.

HelpMeTest can continuously run your Socket.io integration scenarios against your staging environment, catching room leakage bugs and authentication regressions before they reach production.

Read more