Testing Socket.io Applications: Server-Side and Client-Side

Testing Socket.io Applications: Server-Side and Client-Side

Socket.io wraps WebSocket with rooms, namespaces, auto-reconnect, and a binary protocol — all of which require specific testing approaches. The good news: socket.io-client works perfectly in Node.js tests, so you can test the full stack without a browser. This tutorial walks from unit-level handler tests through full integration tests, covering rooms, namespaces, broadcasting, and failure scenarios.

Setting Up the Test Environment

Install the test dependencies:

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

The key insight: socket.io-client is not just for browsers. Import it directly in your Jest tests and it connects to your test server over a real TCP socket. No mocking needed at the transport level.

Testing Basic Event Handlers

Start with a typical Socket.io server:

// server.js
const { Server } = require('socket.io');
const http = require('http');

function createApp() {
  const httpServer = http.createServer();
  const io = new Server(httpServer, {
    cors: { origin: '*' }
  });

  io.on('connection', (socket) => {
    socket.on('ping', (data, callback) => {
      callback({ pong: true, echo: data });
    });

    socket.on('join-room', (roomId) => {
      socket.join(roomId);
      socket.to(roomId).emit('user-joined', { id: socket.id });
    });

    socket.on('room-message', ({ roomId, text }) => {
      io.to(roomId).emit('message', { text, from: socket.id, ts: Date.now() });
    });
  });

  return { httpServer, io };
}

module.exports = { createApp };

Test the ping handler with acknowledgment callback:

// server.test.js
const { io: ioc } = require('socket.io-client');
const { createApp } = require('./server');

let httpServer, io, clientUrl;

beforeAll((done) => {
  ({ httpServer, io } = createApp());
  httpServer.listen(0, () => {
    clientUrl = `http://localhost:${httpServer.address().port}`;
    done();
  });
});

afterAll(() => {
  io.close();
  httpServer.close();
});

function createClient(url = clientUrl, options = {}) {
  return ioc(url, { autoConnect: false, ...options });
}

function waitForEvent(socket, event) {
  return new Promise((resolve, reject) => {
    socket.once(event, resolve);
    socket.once('error', reject);
    setTimeout(() => reject(new Error(`Timeout waiting for ${event}`)), 5000);
  });
}

test('ping returns pong with echo', (done) => {
  const client = createClient();
  client.connect();

  client.on('connect', () => {
    client.emit('ping', { value: 42 }, (response) => {
      expect(response.pong).toBe(true);
      expect(response.echo).toEqual({ value: 42 });
      client.disconnect();
      done();
    });
  });
});

Testing Rooms and Broadcasting

Rooms are where Socket.io shines — and where test setup gets more complex. You need multiple clients:

test('room-message broadcasts to all room members', async () => {
  const alice = createClient();
  const bob = createClient();
  const charlie = createClient(); // not in the room

  alice.connect();
  bob.connect();
  charlie.connect();

  // Wait for all three to connect
  await Promise.all([
    waitForEvent(alice, 'connect'),
    waitForEvent(bob, 'connect'),
    waitForEvent(charlie, 'connect'),
  ]);

  // Both alice and bob join the room
  alice.emit('join-room', 'room-1');
  bob.emit('join-room', 'room-1');
  // charlie does not join

  // Allow join processing
  await new Promise((r) => setTimeout(r, 50));

  const aliceMessages = [];
  const bobMessages = [];
  const charlieMessages = [];

  alice.on('message', (m) => aliceMessages.push(m));
  bob.on('message', (m) => bobMessages.push(m));
  charlie.on('message', (m) => charlieMessages.push(m));

  alice.emit('room-message', { roomId: 'room-1', text: 'hello room' });
  await new Promise((r) => setTimeout(r, 100));

  // Alice AND Bob get the message (io.to broadcasts to all including sender)
  expect(aliceMessages).toHaveLength(1);
  expect(bobMessages).toHaveLength(1);
  expect(charlieMessages).toHaveLength(0); // not in room

  expect(aliceMessages[0].text).toBe('hello room');

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

test('user-joined fires for existing room members when new user joins', async () => {
  const first = createClient();
  const second = createClient();

  first.connect();
  second.connect();

  await Promise.all([waitForEvent(first, 'connect'), waitForEvent(second, 'connect')]);

  first.emit('join-room', 'room-2');
  await new Promise((r) => setTimeout(r, 50));

  const joinNotifications = [];
  first.on('user-joined', (data) => joinNotifications.push(data));

  second.emit('join-room', 'room-2');
  await new Promise((r) => setTimeout(r, 100));

  expect(joinNotifications).toHaveLength(1);
  expect(joinNotifications[0].id).toBe(second.id);

  first.disconnect();
  second.disconnect();
});

Testing Namespaces

Namespaces let you segment Socket.io traffic. Test them by connecting to namespace-specific URLs:

// Add to server.js
const adminNs = io.of('/admin');

adminNs.use((socket, next) => {
  if (socket.handshake.auth.token === 'secret') {
    next();
  } else {
    next(new Error('Unauthorized'));
  }
});

adminNs.on('connection', (socket) => {
  socket.on('broadcast-all', (msg) => {
    io.emit('admin-broadcast', msg); // fires on the main namespace
  });
});
test('admin namespace rejects unauthorized connections', (done) => {
  const client = ioc(`${clientUrl}/admin`, {
    auth: { token: 'wrong' },
    reconnection: false,
  });

  client.on('connect_error', (err) => {
    expect(err.message).toBe('Unauthorized');
    client.disconnect();
    done();
  });

  client.on('connect', () => {
    client.disconnect();
    done(new Error('Should not have connected'));
  });
});

test('admin namespace accepts authorized connections', (done) => {
  const client = ioc(`${clientUrl}/admin`, {
    auth: { token: 'secret' },
  });

  client.on('connect', () => {
    expect(client.connected).toBe(true);
    client.disconnect();
    done();
  });

  client.on('connect_error', (err) => {
    done(new Error(`Should have connected: ${err.message}`));
  });
});

Integration Testing with supertest + socket.io-client

REST endpoints often interact with Socket.io state. Test both together:

// Extend server.js with HTTP endpoint
const express = require('express');

function createAppWithHttp() {
  const app = express();
  app.use(express.json());
  const httpServer = require('http').createServer(app);
  const io = new Server(httpServer);

  // Track connected users
  const connectedUsers = new Map();

  io.on('connection', (socket) => {
    connectedUsers.set(socket.id, { id: socket.id, joined: Date.now() });
    socket.on('disconnect', () => connectedUsers.delete(socket.id));
  });

  // HTTP endpoint to list connected users
  app.get('/api/users', (req, res) => {
    res.json({ users: Array.from(connectedUsers.values()) });
  });

  // HTTP endpoint to send a message to all clients
  app.post('/api/announce', (req, res) => {
    io.emit('announcement', req.body);
    res.json({ ok: true });
  });

  return { app, httpServer, io };
}
// integration.test.js
const request = require('supertest');
const { io: ioc } = require('socket.io-client');
const { createAppWithHttp } = require('./server');

let app, httpServer, io, baseUrl;

beforeAll((done) => {
  ({ app, httpServer, io } = createAppWithHttp());
  httpServer.listen(0, () => {
    baseUrl = `http://localhost:${httpServer.address().port}`;
    done();
  });
});

afterAll(() => { io.close(); httpServer.close(); });

test('GET /api/users reflects connected socket clients', async () => {
  const client = ioc(baseUrl);
  await waitForEvent(client, 'connect');

  const response = await request(app).get('/api/users');
  expect(response.status).toBe(200);
  expect(response.body.users).toHaveLength(1);
  expect(response.body.users[0].id).toBe(client.id);

  client.disconnect();

  // After disconnect, user count drops
  await new Promise((r) => setTimeout(r, 50));
  const afterDisconnect = await request(app).get('/api/users');
  expect(afterDisconnect.body.users).toHaveLength(0);
});

test('POST /api/announce emits to all connected clients', async () => {
  const client = ioc(baseUrl);
  await waitForEvent(client, 'connect');

  const announcements = [];
  client.on('announcement', (data) => announcements.push(data));

  await request(app)
    .post('/api/announce')
    .send({ message: 'system maintenance in 5 minutes' })
    .expect(200);

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

  expect(announcements).toHaveLength(1);
  expect(announcements[0].message).toBe('system maintenance in 5 minutes');

  client.disconnect();
});

Testing Disconnection Events and Error Recovery

Disconnection handling is critical for stateful Socket.io apps:

// Server-side: clean up state on disconnect
io.on('connection', (socket) => {
  socket.on('disconnect', (reason) => {
    // reason: 'transport close', 'server namespace disconnect', 'ping timeout', etc.
    socket.broadcast.emit('user-left', { id: socket.id, reason });
  });
});
test('broadcasts user-left when client disconnects unexpectedly', async () => {
  const observer = createClient();
  const leaving = createClient();

  observer.connect();
  leaving.connect();

  await Promise.all([waitForEvent(observer, 'connect'), waitForEvent(leaving, 'connect')]);

  const leftEvents = [];
  observer.on('user-left', (data) => leftEvents.push(data));

  const leavingId = leaving.id;
  leaving.disconnect(); // graceful disconnect

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

  expect(leftEvents).toHaveLength(1);
  expect(leftEvents[0].id).toBe(leavingId);

  observer.disconnect();
});

test('client reconnects after transient server disconnect', async () => {
  const client = ioc(clientUrl, {
    reconnectionDelay: 50,
    reconnectionDelayMax: 200,
  });

  let connectCount = 0;
  client.on('connect', () => connectCount++);

  client.connect();
  await waitForEvent(client, 'connect');
  expect(connectCount).toBe(1);

  // Force-close all server sockets (simulates server-side disconnect)
  io.disconnectSockets(true);

  // Wait for reconnect
  await new Promise((r) => setTimeout(r, 500));
  expect(connectCount).toBe(2);
  expect(client.connected).toBe(true);

  client.disconnect();
});

Testing Error Events

Socket.io surfaces server errors as connect_error or via custom error events:

// Server middleware that can reject
io.use((socket, next) => {
  const { userId } = socket.handshake.auth;
  if (!userId) return next(new Error('AUTH_REQUIRED'));
  socket.userId = userId;
  next();
});
test('emits connect_error when auth is missing', (done) => {
  const client = ioc(clientUrl, { reconnection: false });

  client.on('connect_error', (err) => {
    expect(err.message).toBe('AUTH_REQUIRED');
    client.disconnect();
    done();
  });
});

test('connects successfully with valid auth', (done) => {
  const client = ioc(clientUrl, { auth: { userId: 'user-123' } });

  client.on('connect', () => {
    expect(client.connected).toBe(true);
    client.disconnect();
    done();
  });

  client.on('connect_error', (err) => {
    done(new Error(`Unexpected auth error: ${err.message}`));
  });
});

Test Cleanup Patterns

Socket.io tests leak if you don't close clients properly. Use a cleanup registry:

// test-helpers/socket.js
const openClients = [];

function createTrackedClient(url, options) {
  const client = ioc(url, { autoConnect: false, ...options });
  openClients.push(client);
  return client;
}

async function closeAllClients() {
  await Promise.all(
    openClients.map(
      (c) => new Promise((resolve) => {
        if (!c.connected) return resolve();
        c.once('disconnect', resolve);
        c.disconnect();
      })
    )
  );
  openClients.length = 0;
}

module.exports = { createTrackedClient, closeAllClients };
afterEach(async () => {
  await closeAllClients();
});

This prevents the common Jest warning about open handles after tests complete.

Running Socket.io Tests in CI

Socket.io integration tests need forceExit in Jest to terminate gracefully:

{
  "jest": {
    "testTimeout": 15000,
    "forceExit": true,
    "globalSetup": "./tests/setup.js"
  }
}

With these patterns — unit tests for middleware and event handlers, integration tests for rooms and namespaces, and combined supertest + socket.io-client tests for REST/Socket.io interactions — you have coverage across every layer of a Socket.io application. The disconnection and error recovery tests are especially valuable: they catch the bugs that only appear when real networks misbehave.

Read more