WebSockets & Real-Time Apps

Testing Real-Time Applications

18 min Lesson 26 of 35

Testing Real-Time Applications

Testing real-time applications requires special approaches due to their asynchronous nature and stateful connections. In this lesson, we'll explore comprehensive testing strategies for WebSocket and Socket.io applications.

Testing WebSocket Connections

Basic WebSocket connection testing involves verifying that connections are established, messages are transmitted, and connections close properly.

// Basic WebSocket test with Jest const WebSocket = require('ws'); describe('WebSocket Connection', () => { let wss, ws; beforeAll((done) => { // Start WebSocket server wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (socket) => { socket.on('message', (message) => { socket.send(`Echo: ${message}`); }); }); done(); }); afterAll(() => { wss.close(); }); test('should connect to WebSocket server', (done) => { ws = new WebSocket('ws://localhost:8080'); ws.on('open', () => { expect(ws.readyState).toBe(WebSocket.OPEN); done(); }); }); test('should send and receive messages', (done) => { const testMessage = 'Hello WebSocket'; ws.on('message', (data) => { expect(data.toString()).toBe(`Echo: ${testMessage}`); ws.close(); done(); }); ws.send(testMessage); }); });

Mocking Socket.io

Socket.io requires specific mocking strategies to test client and server interactions independently.

// Mocking Socket.io client const io = require('socket.io-client'); const ioServer = require('socket.io'); const http = require('http'); describe('Socket.io Server', () => { let httpServer, ioServerInstance, clientSocket; beforeAll((done) => { httpServer = http.createServer(); ioServerInstance = ioServer(httpServer); httpServer.listen(() => { const port = httpServer.address().port; clientSocket = io(`http://localhost:${port}`); ioServerInstance.on('connection', (socket) => { socket.on('ping', (callback) => { callback({ message: 'pong' }); }); }); clientSocket.on('connect', done); }); }); afterAll(() => { ioServerInstance.close(); clientSocket.close(); httpServer.close(); }); test('should respond to ping with pong', (done) => { clientSocket.emit('ping', (response) => { expect(response.message).toBe('pong'); done(); }); }); });

Testing Events and Handlers

Test event handlers to ensure they process data correctly and emit appropriate responses.

// Testing custom events describe('Chat Events', () => { let server, client; beforeEach((done) => { server = createTestServer(); client = createTestClient(server); client.on('connect', done); }); test('should broadcast chat messages', (done) => { const secondClient = createTestClient(server); secondClient.on('chat:message', (data) => { expect(data.username).toBe('TestUser'); expect(data.message).toBe('Hello everyone'); done(); }); client.emit('chat:send', { username: 'TestUser', message: 'Hello everyone' }); }); test('should reject empty messages', (done) => { client.emit('chat:send', { message: '' }, (error) => { expect(error).toBeDefined(); expect(error.code).toBe('EMPTY_MESSAGE'); done(); }); }); test('should emit typing indicators', (done) => { const listener = createTestClient(server); listener.on('user:typing', (data) => { expect(data.username).toBe('TestUser'); expect(data.isTyping).toBe(true); done(); }); client.emit('typing:start', { username: 'TestUser' }); }); });
Best Practice: Use separate test databases and isolated test environments to prevent interference between tests. Clean up connections and resources after each test.

Integration Testing with socket.io-client

Integration tests verify the entire communication flow between client and server.

// Complete integration test const request = require('supertest'); const app = require('../app'); describe('Real-Time Integration', () => { let server, client1, client2; beforeAll((done) => { server = app.listen(3000, () => { client1 = io('http://localhost:3000', { auth: { token: 'user1-token' } }); client2 = io('http://localhost:3000', { auth: { token: 'user2-token' } }); let connected = 0; const checkDone = () => { connected++; if (connected === 2) done(); }; client1.on('connect', checkDone); client2.on('connect', checkDone); }); }); afterAll(() => { client1.close(); client2.close(); server.close(); }); test('should handle room joining and messaging', (done) => { const roomId = 'test-room'; const testMessage = 'Integration test message'; client2.on('room:message', (data) => { expect(data.room).toBe(roomId); expect(data.message).toBe(testMessage); done(); }); client1.emit('room:join', { roomId }, () => { client2.emit('room:join', { roomId }, () => { client1.emit('room:send', { roomId, message: testMessage }); }); }); }); test('should handle user disconnection', (done) => { client2.on('user:left', (data) => { expect(data.userId).toBeDefined(); done(); }); client1.disconnect(); }); });

Testing Reconnection Logic

Reconnection behavior is critical for reliability. Test automatic reconnection and state recovery.

// Testing reconnection describe('Reconnection Behavior', () => { let server, client; beforeEach((done) => { server = createTestServer(); client = io('http://localhost:3000', { reconnection: true, reconnectionDelay: 100, reconnectionAttempts: 3 }); client.on('connect', done); }); test('should reconnect after disconnection', (done) => { let reconnected = false; client.on('reconnect', () => { reconnected = true; expect(client.connected).toBe(true); done(); }); // Simulate server restart server.close(); setTimeout(() => { server = createTestServer(); }, 200); }); test('should restore state after reconnection', (done) => { const roomId = 'persistent-room'; client.emit('room:join', { roomId }, () => { client.on('reconnect', () => { client.emit('room:check', (response) => { expect(response.rooms).toContain(roomId); done(); }); }); // Force reconnection client.io.engine.close(); }); }); test('should emit reconnection attempts', (done) => { let attemptCount = 0; client.on('reconnect_attempt', (attempt) => { attemptCount++; expect(attempt).toBeGreaterThan(0); }); client.on('reconnect_failed', () => { expect(attemptCount).toBe(3); done(); }); // Close server permanently server.close(); client.io.engine.close(); }); });

Load Testing with Artillery

Load testing ensures your real-time application can handle multiple concurrent connections and high message throughput.

# Artillery configuration (artillery.yml) config: target: 'http://localhost:3000' socketio: transports: ['websocket'] phases: - duration: 60 arrivalRate: 10 name: 'Warm up' - duration: 120 arrivalRate: 50 name: 'Sustained load' - duration: 60 arrivalRate: 100 name: 'Peak load' processor: './load-test-processor.js' scenarios: - name: 'Chat Simulation' engine: socketio flow: - emit: channel: 'user:join' data: username: '{{ $randomString() }}' - think: 2 - emit: channel: 'room:join' data: roomId: 'lobby' - think: 3 - loop: - emit: channel: 'chat:send' data: message: '{{ $randomString() }}' - think: 5 count: 10
// Custom Artillery processor module.exports = { generateRandomMessage: function(context, events, done) { const messages = [ 'Hello everyone!', 'How are you?', 'This is a test message', 'WebSocket performance testing' ]; context.vars.randomMessage = messages[Math.floor(Math.random() * messages.length)]; return done(); }, validateResponse: function(context, events, done) { events.on('response', (data) => { if (!data || !data.message) { events.emit('error', 'Invalid response format'); } }); return done(); } };
Load Testing Metrics: Monitor connection count, message latency, memory usage, CPU utilization, and error rates. Use tools like Artillery, k6, or custom Node.js scripts for WebSocket load testing.
// Manual load test script const io = require('socket.io-client'); async function loadTest(concurrentUsers, duration) { const clients = []; const stats = { messagesReceived: 0, messagesSent: 0, errors: 0, latencies: [] }; // Create connections for (let i = 0; i < concurrentUsers; i++) { const client = io('http://localhost:3000'); client.on('message', () => { stats.messagesReceived++; }); client.on('error', () => { stats.errors++; }); clients.push(client); } // Send messages periodically const interval = setInterval(() => { clients.forEach(client => { const startTime = Date.now(); client.emit('ping', () => { stats.latencies.push(Date.now() - startTime); stats.messagesSent++; }); }); }, 1000); // Stop after duration setTimeout(() => { clearInterval(interval); clients.forEach(c => c.close()); const avgLatency = stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length; console.log('Load Test Results:'); console.log(` Concurrent Users: ${concurrentUsers}`); console.log(` Messages Sent: ${stats.messagesSent}`); console.log(` Messages Received: ${stats.messagesReceived}`); console.log(` Errors: ${stats.errors}`); console.log(` Average Latency: ${avgLatency.toFixed(2)}ms`); }, duration); } loadTest(100, 30000); // 100 users for 30 seconds
Practice Exercise:
  1. Write a test suite for a real-time notification system that verifies message delivery to subscribed users
  2. Create integration tests for a multiplayer game lobby (join, leave, ready-up)
  3. Implement a load test that simulates 1000 concurrent chat users
  4. Write tests for reconnection behavior with message queue persistence
  5. Create a test that verifies proper cleanup of disconnected user resources