Unit testing Node.js and WebSockets (Socket.io)

node.jsUnit TestingWebsocketsocket.io

node.js Problem Overview


Could anyone provide a rock-solid, dead-simple unit test for Node.js using WebSockets (Socket.io)?

I'm using socket.io for Node.js, and have looked at socket.io-client for establishing the client connection to a server in the test. However, I seem to be missing something.

In the example below, "worked..." never gets printed out.

var io = require('socket.io-client')
, assert = require('assert')
, expect = require('expect.js');

describe('Suite of unit tests', function() {

    describe('First (hopefully useful) test', function() {

        var socket = io.connect('http://localhost:3001');
        socket.on('connect', function(done) {
            console.log('worked...');
            done();
        });

        it('Doing some things with indexOf()', function() {
            expect([1, 2, 3].indexOf(5)).to.be.equal(-1);
            expect([1, 2, 3].indexOf(0)).to.be.equal(-1);
        });

    });
});

Instead, I simply get:

  Suite of unit tests
    First (hopefully useful) test
      ✓ Doing some things with indexOf() 


  1 test complete (26 ms)

Any suggestions?

node.js Solutions


Solution 1 - node.js

After further poking and prodding, I found some incredibly useful information. In the author's example, he points out the critical step of establishing socket listeners in the before hooks.

This example works:

> Assuming a server is listening for socket connections at localhost:3001, of course

var io = require('socket.io-client')
, assert = require('assert')
, expect = require('expect.js');

describe('Suite of unit tests', function() {

    var socket;
    
    beforeEach(function(done) {
        // Setup
        socket = io.connect('http://localhost:3001', {
            'reconnection delay' : 0
            , 'reopen delay' : 0
            , 'force new connection' : true
        });
        socket.on('connect', function() {
            console.log('worked...');
            done();
        });
        socket.on('disconnect', function() {
            console.log('disconnected...');
        })
    });

    afterEach(function(done) {
        // Cleanup
        if(socket.connected) {
            console.log('disconnecting...');
            socket.disconnect();
        } else {
            // There will not be a connection unless you have done() in beforeEach, socket.on('connect'...)
            console.log('no connection to break...');
        }
        done();
    });
    
    describe('First (hopefully useful) test', function() {
        
        it('Doing some things with indexOf()', function(done) {
            expect([1, 2, 3].indexOf(5)).to.be.equal(-1);
            expect([1, 2, 3].indexOf(0)).to.be.equal(-1);
            done();
        });
        
        it('Doing something else with indexOf()', function(done) {
            expect([1, 2, 3].indexOf(5)).to.be.equal(-1);
            expect([1, 2, 3].indexOf(0)).to.be.equal(-1);
            done();
        });
        
    });
            
});

I found that the placement of done() in the beforeEach, socket.on('connect'...) listener was crucial to having the connection get established. For example, if you comment out done() in the listener, then add it one scope out (just before exiting the beforeEach), you'll see the "no connection to break..." message instead of the "disconnecting..." message. Like so:

beforeEach(function(done) {
    // Setup
    socket = io.connect('http://localhost:3001', {
        'reconnection delay' : 0
        , 'reopen delay' : 0
        , 'force new connection' : true
    });
    socket.on('connect', function() {
        console.log('worked...');
        //done();
    });
    socket.on('disconnect', function() {
        console.log('disconnected...');
    });
    done();
});

I'm new to Mocha, so there's probably a very obvious reason to the initiated for placing done() within the socket scope itself. Hopefully that little detail will save others in my shoes from hair pulling.

For me, the above test (with correct scoping of done()) outputs:

  Suite of unit tests
    First (hopefully useful) test
      ◦ Doing some things with indexOf(): worked...
      ✓ Doing some things with indexOf() 
disconnecting...
disconnected...
      ◦ Doing something else with indexOf(): worked...
      ✓ Doing something else with indexOf() 
disconnecting...
disconnected...


  2 tests complete (93 ms)

Solution 2 - node.js

Offering an extension of the accepted answer here. Has basic client to server communication useful as boilerplate for other future tests. Using mocha, chai, and expect.

var io = require('socket.io-client')
  , io_server = require('socket.io').listen(3001);

describe('basic socket.io example', function() {

  var socket;

  beforeEach(function(done) {
    // Setup
    socket = io.connect('http://localhost:3001', {
      'reconnection delay' : 0
      , 'reopen delay' : 0
      , 'force new connection' : true
      , transports: ['websocket']
    });

    socket.on('connect', () => {
      done();
    });

    socket.on('disconnect', () => {
      // console.log('disconnected...');
    });
  });

  afterEach((done) => {
    // Cleanup
    if(socket.connected) {
      socket.disconnect();
    }
    io_server.close();
    done();
  });

  it('should communicate', (done) => {
    // once connected, emit Hello World
    io_server.emit('echo', 'Hello World');

    socket.once('echo', (message) => {
      // Check that the message matches
      expect(message).to.equal('Hello World');
      done();
    });

    io_server.on('connection', (socket) => {
      expect(socket).to.not.be.null;
    });
  });

});

Solution 3 - node.js

Dealing with callbacks and promises yourself can be difficult and non trivial examples quickly become very complex and hard to read.

There is a tool called socket.io-await-test available via NPM that allows you to suspend/wait in a test until events have been triggered using the await keyword.

  describe("wait for tests", () => {
    it("resolves when a number of events are received", async () => {
        const tester = new SocketTester(client);
        const pongs = tester.on('pong');
        
        client.emit('ping', 1);
        client.emit('ping', 2);
        await pongs.waitForEvents(2) // Blocks until the server emits "pong" twice. 

        assert.equal(pongs.get(0), 2)
        assert.equal(pongs.get(1), 3)
    })
})

Solution 4 - node.js

Check out this boilerplate solution that's based on promises and good practice. You can test your servers entire io events with it, no sweat. You just need to copy a boilerplate test and add your own code as needed.

Checkout the repo on GitHub for full source code.

https://github.com/PatMan10/testing_socketIO_server

const io = require("socket.io-client");
const ev = require("../utils/events");
const logger = require("../utils/logger");

// initSocket returns a promise
// success: resolve a new socket object
// fail: reject a error
const initSocket = () => {
  return new Promise((resolve, reject) => {
      // create socket for communication
      const socket = io("localhost:5000", {
        "reconnection delay": 0,
        "reopen delay": 0,
        "force new connection": true
      });

      // define event handler for sucessfull connection
      socket.on(ev.CONNECT, () => {
        logger.info("connected");
        resolve(socket);
      });

      // if connection takes longer than 5 seconds throw error
      setTimeout(() => {
        reject(new Error("Failed to connect wihtin 5 seconds."));
      }, 5000);
    }
  );
};


// destroySocket returns a promise
// success: resolve true
// fail: resolve false
const destroySocket = socket => {
  return new Promise((resolve, reject) => {
    // check if socket connected
    if (socket.connected) {
      // disconnect socket
      logger.info("disconnecting...");
      socket.disconnect();
      resolve(true);
    } else {
      // not connected
      logger.info("no connection to break...");
      resolve(false);
    }
  });
};

describe("test suit: Echo & Bello", () => {
  test("test: ECHO", async () => {
    // create socket for communication
    const socketClient = await initSocket();

    // create new promise for server response
    const serverResponse = new Promise((resolve, reject) => {
      // define a handler for the test event
      socketClient.on(ev.res_ECHO, data4Client => {
        //process data received from server
        const { message } = data4Client;
        logger.info("Server says: " + message);

        // destroy socket after server responds
        destroySocket(socketClient);

        // return data for testing
        resolve(data4Client);
      });

      // if response takes longer than 5 seconds throw error
      setTimeout(() => {
        reject(new Error("Failed to get reponse, connection timed out..."));
      }, 5000);
    });

    // define data 4 server
    const data4Server = { message: "CLIENT ECHO" };

    // emit event with data to server
    logger.info("Emitting ECHO event");
    socketClient.emit(ev.com_ECHO, data4Server);

    // wait for server to respond
    const { status, message } = await serverResponse;

    // check the response data
    expect(status).toBe(200);
    expect(message).toBe("SERVER ECHO");
  });

  test("test BELLO", async () => {
    const socketClient = await initSocket();
    const serverResponse = new Promise((resolve, reject) => {
      socketClient.on(ev.res_BELLO, data4Client => {
        const { message } = data4Client;
        logger.info("Server says: " + message);
        destroySocket(socketClient);
        resolve(data4Client);
      });

      setTimeout(() => {
        reject(new Error("Failed to get reponse, connection timed out..."));
      }, 5000);
    });

    const data4Server = { message: "CLIENT BELLO" };
    logger.info("Emitting BELLO event");
    socketClient.emit(ev.com_BELLO, data4Server);

    const { status, message } = await serverResponse;
    expect(status).toBe(200);
    expect(message).toBe("SERVER BELLO");
  });
});

---- Foot Note ----

Depending on how you setup your server environment, you may experience environmental conflict between socket.io and socket.io-client running from the same project simultaneously. In which case it would be better to separate the project into a "test client" and a server. Checkout below repo if you get this issue.

https://github.com/PatMan10/testing_socketIO_server_v2

Solution 5 - node.js

In OP's code,

socket.on('connect', function(done) {
    console.log('worked...');
    done();
});

the done was applied to the wrong callback. It should be removed from the socket.on callback and added to Mocha's it block callback:

it('First (hopefully useful) test', function (done) {
  var socket = io.connect('http://localhost:3001');
  socket.on('connect', function () {
    console.log('worked...');
    done();
  });
});

A complete example

Existing answers are great but don't show the server ultimately being tested. Here's a complete version with console.logs to illustrate what's going on. Explanation follows.

src/server.js:
const express = require("express");

const createServer = (port=3000) => {
  const app = express();
  const http = require("http").Server(app);
  const io = require("socket.io")(http);
  
  io.on("connection", socket => {
    console.log("[server] user connected");
    
    socket.on("message", msg => {
      console.log(`[server] received '${msg}'`);
      socket.emit("message", msg);
    });
    socket.on("disconnect", () => {
      console.log("[server] user disconnected");
    });
  });
  
  http.listen(port, () =>
    console.log(`[server] listening on port ${port}`)
  );
  return {
    close: () => http.close(() => 
      console.log("[server] closed")
    )
  };
};
module.exports = {createServer};
test/server.test.js:
const {expect} = require("chai");
const io = require("socket.io-client");
const {createServer} = require("../src/server");
const socketUrl = "http://localhost:3000";

describe("server", function () {
  this.timeout(3000);
  
  let server;
  let sockets;
  beforeEach(() => {
    sockets = [];
    server = createServer();
  });
  afterEach(() => {
    sockets.forEach(e => e.disconnect())
    server.close();
  });
  
  const makeSocket = (id=0) => {
    const socket = io.connect(socketUrl, {
      "reconnection delay": 0,
      "reopen delay": 0,
      "force new connection": true,
      transports: ["websocket"],
    });
    socket.on("connect", () => {
      console.log(`[client ${id}] connected`);
    });
    socket.on("disconnect", () => {
      console.log(`[client ${id}] disconnected`);
    });
    sockets.push(socket);
    return socket;
  };
  
  it("should echo a message to a client", done => {
    const socket = makeSocket();
    socket.emit("message", "hello world");
    socket.on("message", msg => {
      console.log(`[client] received '${msg}'`);
      expect(msg).to.equal("hello world");
      done();
    });
  });
  
  it("should echo messages to multiple clients", () => {
    const sockets = [...Array(5)].map((_, i) => makeSocket(i));
    
    return Promise.all(sockets.map((socket, id) =>
      new Promise((resolve, reject) => {
        const msgs = [..."abcd"].map(e => e + id);
        msgs.slice().forEach(e => socket.emit("message", e));
      
        socket.on("message", msg => {
          console.log(`[client ${id}] received '${msg}'`);
          expect(msg).to.equal(msgs.shift());
          
          if (msgs.length === 0) {
            resolve();
          }
        });
      })
    ));
  });
});

In summary, the server exports a function that lets a server app be created from scratch, allowing each it block to be idempotent and avoid server state from carrying between tests (assuming no persistence on the server otherwise). Creating an app returns an object with a close function. socket.disconnect() must be called per socket in each test to avoid timeouts.

Given these requirements, the testing suite follows this per-test setup/teardown workflow:

let server;
let sockets;
beforeEach(() => {
  sockets = [];
  server = createServer();
});
afterEach(() => {
  sockets.forEach(e => e.disconnect())
  server.close();
});

makeSocket is an optional helper to reduce the repeated boilerplate of connecting and disconnecting a socket client. It does produce a side effect on the sockets array for cleanup later, but this is an implementation detail from the it block's perspective. Test blocks shoudn't touch server or sockets variables, although other workflows are likely depending on need. The critical takeaways are test case idempotency and closing all connections after each test case.

Options on the socket.connect object on the client let you choose transport and behavior of the socket. "force new connection": true creates a new Manager per socket instead of reusing an existing one and transports: ["websocket"] upgrades to WS protocol from long polling immediately.

Use it("should ... ", done => { /* tests */ }); and invoke done() after all work is completed in callbacks or return a promise (and omit the done parameter to the it callback). The example above shows both approaches.


Used in this post:

  • node: 12.19.0
  • chai: 4.2.0
  • express: 4.16.4
  • mocha: 5.2.0
  • socket.io: 2.2.0
  • socket.io-client: 2.2.0

Solution 6 - node.js

I had this problem: How to do unit test with a "socket.io-client" if you don't know how long the server take to respond?.

I've solved so using mocha and chai:

var os = require('os');
var should = require("chai").should();
var socketio_client = require('socket.io-client');

var end_point = 'http://' + os.hostname() + ':8081';
var opts = {forceNew: true};

describe("async test with socket.io", function () {
this.timeout(10000);

it('Response should be an object', function (done) {
    setTimeout(function () {
        var socket_client = socketio_client(end_point, opts);  

        socket_client.emit('event', 'ABCDEF');

        socket_client.on('event response', function (data) {
            data.should.be.an('object');
            socket_client.disconnect();
            done();
        });

        socket_client.on('event response error', function (data) {
            console.error(data);
            socket_client.disconnect();
            done();
            });
        }, 4000);
    });
});

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionmysterluneView Question on Stackoverflow
Solution 1 - node.jsmysterluneView Answer on Stackoverflow
Solution 2 - node.jshelsontView Answer on Stackoverflow
Solution 3 - node.jsAdamView Answer on Stackoverflow
Solution 4 - node.jsPatMan10View Answer on Stackoverflow
Solution 5 - node.jsggorlenView Answer on Stackoverflow
Solution 6 - node.jsjuanmiguelRuaView Answer on Stackoverflow