Skip to content

Instantly share code, notes, and snippets.

@ilyavf
Last active November 4, 2016 19:54
Show Gist options
  • Save ilyavf/c9665a6875d70b68a81ee2aeca4d95df to your computer and use it in GitHub Desktop.
Save ilyavf/c9665a6875d70b68a81ee2aeca4d95df to your computer and use it in GitHub Desktop.
The need to delay socket.io connections for testing

TL;DR

To test a module that uses socket.io we need to intercept a socket.io connection and mock a server responses. If a module tries to establish a socket.io connection immediately on a module evaluation then it won't reach the mocked socket.io.

To overcome the problem we either have to delay the evaluation of the modules we test, or delay establishing the socket.io connection.

The steal-socket package addresses the problem using the second option.

Overview

To test or demo a functionality that uses socket.io we need to mock a socket.io connection and emulate a socket.io server.

The following package can-fixture-socket intercepts socket.io messages and allows to simulate server responses.

Basic usage:

// Import socket-io client:
var io = require("socket.io-client");

// Import fixture module:
var fixtureSocket = require("can-fixture-socket");

// Create a mock server that intercepts socket.io:
var mockServer = new fixtureSocket.Server(io);

// Mock server behavior
mockServer.on("connection", function(){
  mockServer.emit("notifications", {test: "OK"})
});

Technique

To intercept socket.io messages can-fixture-socket overrides io.Manager prototype methods (e.g. open,socket), returns a mocked io.Socket socket, and uses internal pubsub to connect client's socket event listeners with the mocked server.

For this to work new fixtureSocket.Server(io) must happen before any call to io() is done.

Problem

If any imported module of a demo page or a test calls to io() immediately then there is no connection between a client and the mocked server.

A demo page:

<script type="text/stache" can-autorender id='main'>
    NOTE: The following module imports a model that executes `io()` immediately.
    <can-import from="bitcentive/components/contribution-month/" />
    <contribution-month></contribution-month>
</script>
<script src="../../../node_modules/steal/steal.js"
        main="can-view-autorender">

    // Use fixtures:
    import io from "socket.io-client/socket.io";
    import "can-fixture-socket";
    
    // NOTE: the following line gets executed after the above `ContributionMonth` model calls `io()`.
    var mockServer = new fixtureSocket.Server( io );
    
    // mock server responses here ...
</script>

A test:

// import socket fixtures:
import 'bitcentive/models/fixtures/fixtures-socket';

// import module that we want to test:
import { ViewModel } from 'bitcentive/components/contribution-month/';

QUnit.asyncTest('test fixture socket', function(){
    let vm = new ViewModel();

    vm.contributionMonthPromise.then((contributionMonth) => {
      QUnit.equal(contributionMonth.items.length, 3, 'Loaded 3 items');
      QUnit.start();
    });
});

Both demo page and test won't work because the initial call to io will not connect to the mocked server.

Solution

Option A. Delay importing modules

One way to pass around the problem is to use dynamic import of the modules that we are testing / demoing.

Example demo page that has the problem can be fixed with the following modifications:

<script type="text/stache" can-autorender id='main'>
    The following module imports a model that executes `io()` immediately.
    <can-import from="bitcentive/components/contribution-month/" />
    <contribution-month></contribution-month>
</script>
<script src="../../../node_modules/steal/steal.js"
        main="@empty">                                                  // <<<<<< NOTE
    import io from "socket.io-client/socket.io";
    import "can-fixture-socket";
    var mockServer = new fixtureSocket.Server( io );
    // mock server responses here ...
    
    // NOTE: Postpone autorender till socket.io gets mocked:
    // This way the module that we want to test will be evaluated after we mock socket.io.
    System.import('can-view-autorender').then(function(autorender){    // <<<<<< NOTE
        autorender(function(){
            viewModel(document.getElementById("main")).set("contributionMonthId","1");
        });
    });
</script>

Example test can work by postponing the import of the module:

// import socket fixtures:
import 'bitcentive/models/fixtures/fixtures-socket';

// Delayed import of the module that we want to test:
var VMPromise = System.import('bitcentive/components/contribution-month/').then(module => module.ViewModel);

QUnit.asyncTest('test fixture socket', function(){
    // Wait till our module gets loaded:
    VMPromise.then(ViewModel => {
        let vm = new ViewModel();

        vm.contributionMonthPromise.then((contributionMonth) => {
            QUnit.equal(contributionMonth.items.length, 3, 'Loaded 3 items');
            QUnit.start();
        });
    });
});

Option B. Delay socket.io connection

If we can delay socket.io connection (and all calls that create socket event listeners) till we mock a socket.io server we can demo / test as we usually do (see demo and test as described in the "Problem" section).

Technique

  1. Use steal-socket.io instead of socket.io-client
  2. The steal-socket.io package exposes a wrapped io function.
  3. It records all calls to io() and to socket.on(...) (socket.emit(...), etc) to a FIFO storage.
  4. On steal.done() it replays the recorded calls applying them to real io socket and then continue as a proxy.
// The following app:
var socket = io("localhost");
socket.on("messages", function(m){ console.log(m); })
socket.emit("hello", {});

// will create:
fifoSockets = {
    "localhost": {
        url: "localhost",
        realSocket: null,
        fifo: [
            [io, ["localhost"]],
            ["on", ["messages", function(m){ console.log(m); }]],
            ["emit", ["hello", {}]]
        ]
    }
}

The package exposes a funcion that will return a delayed socket instead of a regular io socket. Which is mocked socket with methods like on/off/emit/once.

And then on steal.done() the package will replay the recorded calls applying them to real io:

steal.done().then(() => {
    var fifo = fifoSockets.localhost.fifo
        io = fifo[0][0],
        ioArgs = fifo[0][1];
        
    // Replay call to io:
    var realSocket = io.apply(null, ioArgs);
    
    // Replay calls to io socket:
    fifo.forEach( [method, args] => {
        realSocket[method].apply(realSocket, args);
    }
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment