Testing Socket.io Applications: Unit, Integration, and E2E

Testing Socket.io Applications: Unit, Integration, and E2E

Socket.io adds a significant abstraction layer over raw WebSockets — rooms, namespaces, acknowledgements, automatic reconnection, fallback transports. Each of these features introduces new testing surface area. This guide walks through every layer, from unit-testing event handlers in isolation to running full E2E scenarios with supertest and socket.io-client.

The Testing Stack

For Socket.io testing you need three things working together:

npm install --save-dev socket.io-client supertest jest

The key insight is that socket.io-client can connect to a server running in-process during tests. You don't need a running server on a fixed port — you create the http.Server, pass it to new Server(httpServer), listen on port 0, and connect the client to the same server instance.

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

function createSocketTestServer(setupServer) {
  const httpServer = createServer();
  const io = new Server(httpServer, {
    // Disable polling fallback for cleaner tests
    transports: ['websocket'],
  });

  setupServer(io);

  return new Promise((resolve) => {
    httpServer.listen(() => {
      const { port } = httpServer.address();
      const baseUrl = `http://localhost:${port}`;

      function connect(namespace = '/', options = {}) {
        return ioc(`${baseUrl}${namespace}`, {
          transports: ['websocket'],
          ...options,
        });
      }

      resolve({
        io,
        httpServer,
        connect,
        close: () => new Promise((res) => io.close(res)),
      });
    });
  });
}

module.exports = { createSocketTestServer };

Testing Basic Event Emission and Reception

Start with the simplest case: a client emits an event, the server handles it, and broadcasts a response.

const { createSocketTestServer } = require('./test-helpers/create-socket-server');

describe('Basic event handling', () => {
  let server, client;

  beforeEach(async () => {
    server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('greet', (name) => {
          socket.emit('greeting', `Hello, ${name}!`);
        });
      });
    });

    client = server.connect();
    await new Promise((resolve) => client.on('connect', resolve));
  });

  afterEach(async () => {
    client.disconnect();
    await server.close();
  });

  test('server responds to greet event', async () => {
    const response = new Promise((resolve) => {
      client.on('greeting', resolve);
    });

    client.emit('greet', 'Alice');

    expect(await response).toBe('Hello, Alice!');
  });
});

Testing Acknowledgements

Acknowledgements are Socket.io's request-reply pattern — the emitter passes a callback as the last argument, and the receiver calls it with a response. They're widely used for operations that need confirmation (joining a room, submitting data). Testing them requires a slightly different pattern.

describe('Acknowledgements', () => {
  let server, client;

  beforeEach(async () => {
    server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('createRoom', (roomName, callback) => {
          if (!roomName || roomName.length < 3) {
            return callback({ error: 'Room name too short' });
          }
          socket.join(roomName);
          callback({ success: true, roomId: roomName.toLowerCase() });
        });
      });
    });

    client = server.connect();
    await new Promise((resolve) => client.on('connect', resolve));
  });

  afterEach(async () => {
    client.disconnect();
    await server.close();
  });

  test('ack returns success for valid room name', async () => {
    const result = await new Promise((resolve) => {
      client.emit('createRoom', 'general', resolve);
    });

    expect(result.success).toBe(true);
    expect(result.roomId).toBe('general');
  });

  test('ack returns error for short room name', async () => {
    const result = await new Promise((resolve) => {
      client.emit('createRoom', 'ab', resolve);
    });

    expect(result.error).toBe('Room name too short');
  });

  test('ack times out if server never calls back', async () => {
    // Set up a server that ignores the event
    const silentServer = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('silentEvent', () => {}); // never calls callback
      });
    });

    const silentClient = silentServer.connect();
    await new Promise((resolve) => silentClient.on('connect', resolve));

    const timedOut = await new Promise((resolve) => {
      const timeout = setTimeout(() => resolve(true), 200);
      silentClient.emit('silentEvent', () => {
        clearTimeout(timeout);
        resolve(false);
      });
    });

    expect(timedOut).toBe(true);
    silentClient.disconnect();
    await silentServer.close();
  });
});

The timeout test is important: if your server handler throws before calling the ack callback, the client will wait forever. Document your expected ack latency and test that it's always met.

Testing Rooms

Rooms are the most common Socket.io feature and the most commonly undertested. You need to verify that messages reach the right sockets and don't leak to others.

describe('Room-based messaging', () => {
  let server;

  beforeEach(async () => {
    server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('joinRoom', (room, cb) => {
          socket.join(room);
          cb({ joined: room });
        });

        socket.on('messageRoom', ({ room, text }) => {
          io.to(room).emit('roomMessage', { text, from: socket.id });
        });
      });
    });
  });

  afterEach(() => server.close());

  test('message reaches all room members', async () => {
    const alice = server.connect();
    const bob = server.connect();
    const carol = server.connect(); // Not in room

    await Promise.all([
      new Promise((res) => alice.on('connect', res)),
      new Promise((res) => bob.on('connect', res)),
      new Promise((res) => carol.on('connect', res)),
    ]);

    // Alice and Bob join 'team-alpha'
    await new Promise((res) => alice.emit('joinRoom', 'team-alpha', res));
    await new Promise((res) => bob.emit('joinRoom', 'team-alpha', res));

    const aliceMessages = [];
    const bobMessages = [];
    const carolMessages = [];

    alice.on('roomMessage', (msg) => aliceMessages.push(msg));
    bob.on('roomMessage', (msg) => bobMessages.push(msg));
    carol.on('roomMessage', (msg) => carolMessages.push(msg));

    alice.emit('messageRoom', { room: 'team-alpha', text: 'hello team' });

    await new Promise((res) => setTimeout(res, 50));

    expect(aliceMessages).toHaveLength(1);
    expect(bobMessages).toHaveLength(1);
    expect(carolMessages).toHaveLength(0); // Carol should NOT receive it
    expect(aliceMessages[0].text).toBe('hello team');

    alice.disconnect();
    bob.disconnect();
    carol.disconnect();
  });

  test('socket leaves room on disconnect', async () => {
    const alice = server.connect();
    await new Promise((res) => alice.on('connect', res));
    await new Promise((res) => alice.emit('joinRoom', 'alpha', res));

    alice.disconnect();
    await new Promise((res) => setTimeout(res, 50));

    // Get room membership from server side
    const members = await server.io.in('alpha').fetchSockets();
    expect(members).toHaveLength(0);
  });
});

Testing Namespaces

Namespaces allow you to create isolated communication channels on the same server. A common pattern is /admin for privileged operations and / for public traffic.

describe('Namespace isolation', () => {
  let server;

  beforeEach(async () => {
    server = await createSocketTestServer((io) => {
      // Public namespace
      io.on('connection', (socket) => {
        socket.on('publicMessage', (msg) => {
          io.emit('broadcast', { from: 'public', msg });
        });
      });

      // Admin namespace with auth middleware
      const adminNs = io.of('/admin');
      adminNs.use((socket, next) => {
        const token = socket.handshake.auth.token;
        if (token === 'secret-admin-token') {
          next();
        } else {
          next(new Error('Unauthorized'));
        }
      });

      adminNs.on('connection', (socket) => {
        socket.on('adminAction', (action, cb) => {
          cb({ performed: action, by: 'admin' });
        });
      });
    });
  });

  afterEach(() => server.close());

  test('admin namespace rejects invalid token', async () => {
    const client = server.connect('/admin', {
      auth: { token: 'wrong-token' },
    });

    const error = await new Promise((resolve) => {
      client.on('connect_error', resolve);
    });

    expect(error.message).toBe('Unauthorized');
    client.disconnect();
  });

  test('admin namespace accepts valid token', async () => {
    const adminClient = server.connect('/admin', {
      auth: { token: 'secret-admin-token' },
    });

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

    const result = await new Promise((resolve) => {
      adminClient.emit('adminAction', 'deleteUser', resolve);
    });

    expect(result.performed).toBe('deleteUser');
    adminClient.disconnect();
  });

  test('public messages do not reach admin namespace', async () => {
    const publicClient = server.connect('/');
    const adminClient = server.connect('/admin', {
      auth: { token: 'secret-admin-token' },
    });

    await Promise.all([
      new Promise((res) => publicClient.on('connect', res)),
      new Promise((res) => adminClient.on('connect', res)),
    ]);

    const adminBroadcasts = [];
    adminClient.on('broadcast', (msg) => adminBroadcasts.push(msg));

    publicClient.emit('publicMessage', 'hello public');
    await new Promise((res) => setTimeout(res, 50));

    expect(adminBroadcasts).toHaveLength(0);

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

Testing Event Ordering

Order matters in real-time applications. If your client joins a room and immediately sends a message, the server must process the join before the message. Test that your server handles events in the order they arrive.

describe('Event ordering', () => {
  test('join-then-message arrives in correct order', async () => {
    const processedEvents = [];

    const server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('join', (room, cb) => {
          // Simulate async DB check
          setImmediate(() => {
            socket.join(room);
            processedEvents.push(`joined:${room}`);
            cb({ ok: true });
          });
        });

        socket.on('msg', (data) => {
          processedEvents.push(`msg:${data.text}`);
          // Reflect message to room
          io.to(data.room).emit('roomMsg', data.text);
        });
      });
    });

    const client = server.connect();
    await new Promise((res) => client.on('connect', res));

    // Join and immediately send message
    await new Promise((res) => client.emit('join', 'room1', res));
    client.emit('msg', { room: 'room1', text: 'hi' });

    await new Promise((res) => setTimeout(res, 100));

    // Verify join was processed before message
    const joinIdx = processedEvents.indexOf('joined:room1');
    const msgIdx = processedEvents.indexOf('msg:hi');
    expect(joinIdx).toBeLessThan(msgIdx);

    client.disconnect();
    await server.close();
  });
});

Testing Disconnection Handling

Disconnection testing is frequently skipped. You need to test both graceful disconnects (the client calls socket.disconnect()) and abrupt ones (simulating network loss).

describe('Disconnection handling', () => {
  test('server cleans up on graceful disconnect', async () => {
    const activeUsers = new Map();

    const server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('register', (username) => {
          activeUsers.set(socket.id, username);
          io.emit('userJoined', username);
        });

        socket.on('disconnect', () => {
          const username = activeUsers.get(socket.id);
          activeUsers.delete(socket.id);
          if (username) {
            io.emit('userLeft', username);
          }
        });
      });
    });

    const client = server.connect();
    await new Promise((res) => client.on('connect', res));

    const secondClient = server.connect();
    const userEvents = [];
    secondClient.on('userJoined', (u) => userEvents.push(`joined:${u}`));
    secondClient.on('userLeft', (u) => userEvents.push(`left:${u}`));

    await new Promise((res) => secondClient.on('connect', res));

    client.emit('register', 'alice');
    await new Promise((res) => setTimeout(res, 30));

    client.disconnect();
    await new Promise((res) => setTimeout(res, 30));

    expect(userEvents).toContain('joined:alice');
    expect(userEvents).toContain('left:alice');
    expect(activeUsers.size).toBe(0);

    secondClient.disconnect();
    await server.close();
  });

  test('server handles abrupt disconnection', async () => {
    let disconnectReason = null;

    const server = await createSocketTestServer((io) => {
      io.on('connection', (socket) => {
        socket.on('disconnect', (reason) => {
          disconnectReason = reason;
        });
      });
    });

    const client = server.connect();
    await new Promise((res) => client.on('connect', res));

    // Simulate abrupt close (no close frame)
    client.io.engine.close();

    await new Promise((res) => setTimeout(res, 100));

    // transport close or transport error depending on how engine closes
    expect(['transport close', 'transport error']).toContain(disconnectReason);

    await server.close();
  });
});

Integration Testing with Supertest

For HTTP endpoints that interact with Socket.io state (e.g., an API that broadcasts to a room), combine supertest with a connected socket:

const request = require('supertest');
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const { io: ioc } = require('socket.io-client');

describe('HTTP + Socket.io integration', () => {
  let httpServer, io, app;

  beforeEach(async () => {
    app = express();
    app.use(express.json());
    httpServer = createServer(app);
    io = new Server(httpServer, { transports: ['websocket'] });

    io.on('connection', () => {});

    app.post('/notify', (req, res) => {
      const { room, message } = req.body;
      io.to(room).emit('notification', message);
      res.json({ sent: true });
    });

    await new Promise((res) => httpServer.listen(0, res));
  });

  afterEach(() => new Promise((res) => io.close(res)));

  test('POST /notify broadcasts to correct room', async () => {
    const { port } = httpServer.address();
    const client = ioc(`http://localhost:${port}`, {
      transports: ['websocket'],
    });

    await new Promise((res) => client.on('connect', res));
    await new Promise((res) => client.emit('join', 'alerts', res) || res()); // may not have join handler
    // Manually join via server API
    const sockets = await io.fetchSockets();
    await sockets[0].join('alerts');

    const received = new Promise((res) => client.on('notification', res));

    await request(httpServer)
      .post('/notify')
      .send({ room: 'alerts', message: 'Deploy complete' })
      .expect(200)
      .expect({ sent: true });

    expect(await received).toBe('Deploy complete');
    client.disconnect();
  });
});

Common Pitfalls and How to Avoid Them

Race conditions in tests: Always await the connect event before emitting. Emitting before connect fires silently buffers the event, and it may arrive before the server registers the handler.

Multiple clients sharing state: Each server.connect() creates an independent socket with its own ID. If your server stores state per-socket, make sure test setup creates separate clients for each simulated user.

Forgetting to disconnect: Undisposed sockets in afterEach cause Jest did not exit one second after the test run has completed warnings. Always call client.disconnect() and await server.close().

Testing the wrong layer: Don't test Socket.io internals (e.g., that socket.join() works). Test your application's behavior given Socket.io's primitives. The library is already tested.

Flaky timing tests: If you find yourself writing setTimeout(resolve, 500) to wait for something, extract it into a proper waitFor helper that polls with a shorter interval and a maximum timeout.

function waitFor(condition, timeout = 1000, interval = 20) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const check = () => {
      if (condition()) return resolve();
      if (Date.now() - start > timeout) return reject(new Error('Timeout'));
      setTimeout(check, interval);
    };
    check();
  });
}

// Usage
await waitFor(() => receivedMessages.length === 3);

This pattern makes timing-sensitive tests far more reliable across different CI environments without inflating test runtime.

Read more

Start now free