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 jestThe 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.