Socket.IO Testing Guide: Test WebSocket Apps with Jest and Mocha

Socket.IO Testing Guide: Test WebSocket Apps with Jest and Mocha

Socket.IO is the most popular WebSocket library for Node.js — it powers real-time chat apps, multiplayer games, collaborative tools, and live dashboards. Testing Socket.IO requires a different approach than testing REST APIs: you're testing bidirectional event streams, not request/response pairs. Here's everything you need.

Socket.IO Testing Challenges

The main challenges when testing Socket.IO:

  1. Asynchronous events — you emit an event and wait for the other side to respond
  2. Bidirectional — server and client both emit events
  3. Rooms and namespaces — events may only go to specific groups
  4. Connection state — connected, disconnected, reconnecting
  5. Authentication — middleware runs before connection is established

Setting Up Your Test Environment

npm install --save-dev jest socket.io socket.io-client @types/jest
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  testTimeout: 10000,  // Socket.IO tests need more time
  setupFilesAfterFramework: ['./tests/setup.js']
};

Testing the Socket.IO Server (Unit Tests)

The key to unit testing Socket.IO is using socket.io's built-in testing utilities — no real network required:

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

function createSocketServer(httpServer) {
  const io = new Server(httpServer, {
    cors: { origin: '*' }
  });
  
  io.use((socket, next) => {
    // Auth middleware
    const token = socket.handshake.auth.token;
    if (!token) return next(new Error('Authentication required'));
    socket.userId = verifyToken(token);
    next();
  });
  
  io.on('connection', (socket) => {
    socket.on('join-room', (roomId) => {
      socket.join(roomId);
      socket.to(roomId).emit('user-joined', { userId: socket.userId });
    });
    
    socket.on('send-message', ({ roomId, text }) => {
      const message = {
        id: generateId(),
        userId: socket.userId,
        text,
        timestamp: Date.now()
      };
      io.to(roomId).emit('new-message', message);
    });
    
    socket.on('disconnect', () => {
      io.emit('user-left', { userId: socket.userId });
    });
  });
  
  return io;
}

module.exports = { createSocketServer };
// tests/server.test.js
const { createServer } = require('http');
const { Server } = require('socket.io');
const Client = require('socket.io-client');
const { createSocketServer } = require('../src/server');

describe('Socket.IO Server', () => {
  let io, httpServer, serverAddr;
  
  beforeEach((done) => {
    httpServer = createServer();
    io = createSocketServer(httpServer);
    httpServer.listen(() => {
      serverAddr = `http://localhost:${httpServer.address().port}`;
      done();
    });
  });
  
  afterEach((done) => {
    io.close();
    httpServer.close(done);
  });
  
  function connect(token = 'valid-test-token') {
    return Client(serverAddr, {
      auth: { token },
      transports: ['websocket']
    });
  }

  test('connects successfully with valid token', (done) => {
    const client = connect('valid-token');
    client.on('connect', () => {
      expect(client.connected).toBe(true);
      client.disconnect();
      done();
    });
    client.on('connect_error', done);
  });

  test('rejects connection without token', (done) => {
    const client = Client(serverAddr, {
      auth: {},
      transports: ['websocket']
    });
    client.on('connect_error', (err) => {
      expect(err.message).toBe('Authentication required');
      done();
    });
    client.on('connect', () => done(new Error('Should not connect')));
  });

  test('notifies room when user joins', (done) => {
    const client1 = connect('token-1');
    const client2 = connect('token-2');
    let connectedCount = 0;
    
    function onBothConnected() {
      connectedCount++;
      if (connectedCount < 2) return;
      
      // client1 joins room first
      client1.emit('join-room', 'room-42');
      
      // client2 joins and should notify client1
      client2.on('user-joined', (data) => {
        expect(data.userId).toBeDefined();
        client1.disconnect();
        client2.disconnect();
        done();
      });
      
      setTimeout(() => {
        client2.emit('join-room', 'room-42');
      }, 50);
    }
    
    client1.on('connect', onBothConnected);
    client2.on('connect', onBothConnected);
  });

  test('delivers message to all room members', (done) => {
    const sender = connect('token-sender');
    const receiver = connect('token-receiver');
    let ready = 0;
    
    function onReady() {
      ready++;
      if (ready < 2) return;
      
      sender.emit('join-room', 'chat-room');
      receiver.emit('join-room', 'chat-room');
      
      receiver.on('new-message', (msg) => {
        expect(msg.text).toBe('Hello everyone!');
        expect(msg.timestamp).toBeLessThanOrEqual(Date.now());
        expect(msg.id).toBeDefined();
        sender.disconnect();
        receiver.disconnect();
        done();
      });
      
      setTimeout(() => {
        sender.emit('send-message', { roomId: 'chat-room', text: 'Hello everyone!' });
      }, 100);
    }
    
    sender.on('connect', onReady);
    receiver.on('connect', onReady);
  });

  test('message not delivered to users outside room', (done) => {
    const inRoom = connect('in-room');
    const outOfRoom = connect('out-of-room');
    let outOfRoomReceived = false;
    
    inRoom.on('connect', () => {
      outOfRoom.on('connect', () => {
        inRoom.emit('join-room', 'private-room');
        // outOfRoom stays out of the room
        
        outOfRoom.on('new-message', () => {
          outOfRoomReceived = true;
        });
        
        setTimeout(() => {
          inRoom.emit('send-message', { roomId: 'private-room', text: 'Private message' });
          setTimeout(() => {
            expect(outOfRoomReceived).toBe(false);
            inRoom.disconnect();
            outOfRoom.disconnect();
            done();
          }, 200);
        }, 100);
      });
    });
  });

  test('broadcasts disconnect event when client leaves', (done) => {
    const observer = connect('observer');
    const leaver = connect('leaver');
    
    observer.on('connect', () => {
      leaver.on('connect', () => {
        observer.on('user-left', (data) => {
          expect(data.userId).toBeDefined();
          observer.disconnect();
          done();
        });
        setTimeout(() => leaver.disconnect(), 100);
      });
    });
  });
});

Testing with Promises (Cleaner Async)

Convert Socket.IO callbacks to promises for cleaner test code:

// tests/helpers/socket-helpers.js

function waitForEvent(socket, eventName, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(`Timeout waiting for event: ${eventName}`));
    }, timeout);
    
    socket.once(eventName, (data) => {
      clearTimeout(timer);
      resolve(data);
    });
  });
}

function connectAndWait(url, options = {}) {
  return new Promise((resolve, reject) => {
    const socket = Client(url, { transports: ['websocket'], ...options });
    socket.once('connect', () => resolve(socket));
    socket.once('connect_error', reject);
  });
}

module.exports = { waitForEvent, connectAndWait };
// tests/server-promises.test.js
const { waitForEvent, connectAndWait } = require('./helpers/socket-helpers');

describe('Socket.IO with Promises', () => {
  test('delivers message using promises', async () => {
    const sender = await connectAndWait(serverAddr, { auth: { token: 'tok1' } });
    const receiver = await connectAndWait(serverAddr, { auth: { token: 'tok2' } });
    
    sender.emit('join-room', 'async-room');
    receiver.emit('join-room', 'async-room');
    
    // Wait for both to join
    await new Promise(r => setTimeout(r, 100));
    
    sender.emit('send-message', { roomId: 'async-room', text: 'Async test message' });
    
    const message = await waitForEvent(receiver, 'new-message');
    
    expect(message.text).toBe('Async test message');
    
    sender.disconnect();
    receiver.disconnect();
  });

  test('rejects with timeout when event never arrives', async () => {
    const client = await connectAndWait(serverAddr, { auth: { token: 'tok' } });
    
    await expect(
      waitForEvent(client, 'nonexistent-event', 500)
    ).rejects.toThrow('Timeout waiting for event: nonexistent-event');
    
    client.disconnect();
  });
});

Testing Middleware

// tests/middleware.test.js
describe('Socket.IO Authentication Middleware', () => {
  test('middleware can add data to socket', async () => {
    const client = await connectAndWait(serverAddr, {
      auth: { token: 'user-token-abc' }
    });
    
    // Request socket info to verify middleware ran
    client.emit('get-my-info');
    const info = await waitForEvent(client, 'my-info');
    
    expect(info.userId).toBeDefined();
    client.disconnect();
  });

  test('middleware receives handshake data', (done) => {
    const client = Client(serverAddr, {
      auth: { token: 'valid-token' },
      query: { version: '2.0' },
      transports: ['websocket']
    });
    
    // Verify on server side that middleware received the right data
    io.use((socket, next) => {
      expect(socket.handshake.auth.token).toBe('valid-token');
      expect(socket.handshake.query.version).toBe('2.0');
      next();
    });
    
    client.on('connect', () => {
      client.disconnect();
      done();
    });
  });
});

Testing Namespaces

// tests/namespaces.test.js
describe('Socket.IO Namespaces', () => {
  test('admin namespace requires admin role', (done) => {
    const regular = Client(`${serverAddr}/admin`, {
      auth: { token: 'regular-user-token' },
      transports: ['websocket']
    });
    
    regular.on('connect_error', (err) => {
      expect(err.message).toBe('Admin access required');
      done();
    });
    regular.on('connect', () => done(new Error('Should not connect')));
  });

  test('events in one namespace dont leak to another', async () => {
    const chatClient = await connectAndWait(`${serverAddr}/chat`, {
      auth: { token: 'user-token' }
    });
    const gameClient = await connectAndWait(`${serverAddr}/game`, {
      auth: { token: 'user-token' }
    });
    
    let chatReceived = false;
    gameClient.on('chat-message', () => { chatReceived = true; });
    
    chatClient.emit('send-message', { text: 'Test' });
    await new Promise(r => setTimeout(r, 200));
    
    expect(chatReceived).toBe(false);
    chatClient.disconnect();
    gameClient.disconnect();
  });
});

Testing with Mocha and Chai

If you prefer Mocha:

// tests/server.mocha.test.js
const { expect } = require('chai');
const { createServer } = require('http');
const { Server } = require('socket.io');
const Client = require('socket.io-client');
const { promisify } = require('util');

describe('Socket.IO with Mocha', function() {
  this.timeout(10000);
  
  let io, server;
  
  before((done) => {
    server = createServer();
    io = new Server(server);
    server.listen(done);
  });
  
  after((done) => {
    io.close();
    server.close(done);
  });
  
  it('server emits after receiving event', function(done) {
    const addr = `http://localhost:${server.address().port}`;
    io.on('connection', (socket) => {
      socket.on('ping', () => socket.emit('pong'));
    });
    
    const client = Client(addr, { transports: ['websocket'] });
    client.on('pong', () => {
      client.disconnect();
      done();
    });
    client.on('connect', () => client.emit('ping'));
  });
  
  it('server can emit to all clients', function() {
    const addr = `http://localhost:${server.address().port}`;
    const clients = [Client(addr), Client(addr), Client(addr)];
    
    return Promise.all([
      new Promise(resolve => clients[0].on('broadcast', resolve)),
      new Promise(resolve => clients[1].on('broadcast', resolve)),
      new Promise(resolve => clients[2].on('broadcast', resolve)),
      new Promise(resolve => setTimeout(() => {
        io.emit('broadcast', { msg: 'Hello all' });
        resolve();
      }, 100))
    ]).then(() => {
      clients.forEach(c => c.disconnect());
    });
  });
});

End-to-End Testing with HelpMeTest

Socket.IO unit tests verify your server logic. End-to-end tests verify the user experience. HelpMeTest can test your Socket.IO-powered UIs:

*** Test Cases ***
Chat Message Appears In Real Time
    Open Browser    https://your-chat-app.com    Chrome    alias=user1
    Login    user1@example.com    password1
    Go To    https://your-chat-app.com/chat/room-1
    
    Open Browser    https://your-chat-app.com    Chrome    alias=user2
    Login    user2@example.com    password2
    Go To    https://your-chat-app.com/chat/room-1
    
    Switch Browser    user1
    Fill Text    #message-input    Hello from user 1!
    Click    #send-button
    
    Switch Browser    user2
    Wait Until Element Contains    .message-list    Hello from user 1!    timeout=5s

Live User Count Updates
    Go To    https://your-app.com/room
    ${count_before}=    Get Text    .online-count
    # Open second browser window (increases online count)
    Execute Script    window.open('https://your-app.com/room')
    Wait Until Element Is Not    .online-count    ${count_before}    timeout=5s

Summary

  • Use in-process test servers — no need to spin up external servers for unit tests
  • Convert callbacks to promises with waitForEvent helper for cleaner async tests
  • Test room isolation — events must not leak between rooms
  • Test auth middleware — connection should fail cleanly without valid credentials
  • Test disconnection behavior — other clients should be notified when someone leaves
  • Use testTimeout: 10000 in Jest config — Socket.IO handshakes need time
  • Test namespaces separately — namespace isolation is a separate concern from room isolation
  • Add end-to-end tests to verify users see real-time updates in the browser

Read more