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:
- Asynchronous events — you emit an event and wait for the other side to respond
- Bidirectional — server and client both emit events
- Rooms and namespaces — events may only go to specific groups
- Connection state — connected, disconnected, reconnecting
- 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=5sSummary
- Use in-process test servers — no need to spin up external servers for unit tests
- Convert callbacks to promises with
waitForEventhelper 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: 10000in 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