A Service Worker in place of Sinon.js

Posted: Mar 23, 2019

Sinon.js is a nice library which I've used in a number of projects for stubbing XMLHttpRequest. However, technologies have changed since the library was created. Now, we have Service Workers which are capable of doing even more.

As you might know service workers (SW) catch all HTTP requests happening on a page. So, to stub HTTP requests, tests must be run in a real browser (yeah, the solution won't work with libraries which mimic browsers).

The SW is a little bit tricky to debug, it is helpful to run tests without the headless mode, thus, Dev Tools will show errors related to the SW. Once the solution works, you can switch to the headless mode again.

There are 3 parts in the solution:

The container with stubs

It is a simple object which keeps stubs for every HTTP method.

// test/support/http_stubs.js

const HttpStubs = {
  // default stubs
  items: {
    'POST': {},
    'GET': {},
    'DELETE': {}
  },

  register: function(details) {
    this.items[details.method][details.url] = details;
  },

  find: function(method, url) {
    for(let key in this.items[method]){
      let details = this.items[method][key];

      if (details.url.test(url))
        return details;
    }
  }
};

export default HttpStubs;

The register method adds a new stub.

HttpStubs.register({
  method: 'POST',
  url: /\/countries/,
  response: {countries: []}
});

The URL should be a regular expression, thus, unnecessary parts (for example, a domain) might be omitted. A stub for the same URL will override the already defined one.

The find method finds a suitable stub for the given method and URL.

HttpStubs.find('POST', 'https://example.org/users')

The items property might contain default stubs which might cover common requests. In this case, it will play a role of fixtures (define and forget).

The service worker

This is a core of the solution.

// test/support/sw.js

import HttpStubs from './http_stubs';

let latestRequest;

self.addEventListener('install', function(event) {
  // activate once the worker gets installed,
  // kick out any previous version of the worker
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', function(event) {
  // immediately take control over pages
  event.waitUntil(self.clients.claim());
});

self.addEventListener('message', function(event) {
  let port = event.ports[0];

  let command = event.data.command,
      details = event.data.details;

  if (command === 'latestRequest') {
    // read body
    latestRequest.body.then((body) => {
      latestRequest.body = body;
      port.postMessage(latestRequest);
    });
  }

  if (command === 'stubRequest') {
    HttpStubs.register(details);
    // just send something to tell that it is registered
    port.postMessage({done: true});
  }
});

self.addEventListener('fetch', function(event) {
  var req = event.request,
      stub = HttpStubs.find(req.method, req.url);

  if (stub) {
    latestRequest = {
      method: req.method,
      url:    req.url,
      body:   req.json() // a promise object
    };

    event.respondWith(new Response(JSON.stringify(stub.response)));
  }
});

The message handler is a part of the communication protocol between the SW and tests. The communication is achieved via a message channel which provides functionality for sending a message to the SW and receive a response from it. Basically, tests will open a channel to the SW, all messages from the channel will reach the message handler. This handler expects objects as a message which must contain a command property. The handler supports 2 commands:

{
  command: 'stubRequest',
  details: {
    method:   'POST',
    url:      /\/countries/,
    response: {countries: []}
  }
}
{
  command: 'latestRequest'
}

The fetch handler simply tries to find stubs for requests. If there is no stub, the SW will leave the request out.

The helpers for tests

It defines 3 public methods and one sort of private.

// test/helpers.js

const Helpers = {
  registerHttpStubs: async function() {
    navigator.serviceWorker.register('/test/support/sw.js');

    // wait for activation, so the client can communicate with SW
    this.swHttpStubsRegistration = await navigator.serviceWorker.ready;
    this.swHttpStubs = this.swHttpStubsRegistration.active;
  },

  stubRequest: function(method, url, response) {
    return this.sendMsgToSwHttpStubs({
      command: 'stubRequest',
      details: {
        method:   method,
        url:      url,
        response: response
      }
    }, this.swHttpStubs);
  },

  latestRequest: function() {
    return this.sendMsgToSwHttpStubs({command: 'latestRequest'});
  },

  sendMsgToSwHttpStubs: function(msg) {
    return new Promise((resolve, reject) => {
      var swChannel = new MessageChannel();

      // handler for receiving a message reply from the SW
      swChannel.port1.onmessage = (event) => {
        resolve(event.data);
      };

      // send a message to the SW along with the port for reply
      this.swHttpStubs.postMessage(
        msg,
        [swChannel.port2]
      );
    });
  }
};

export default Helpers;

The registerHttpStubs registers the SW, so it starts to intercept requests.

beforeEach(function() {
  return helpers.registerHttpStubs();
});

The stubRequest adds a new stub to the container.

test('shows countries in the dropdown', async function() {
  await helpers.stubRequest('GET', /\/countries/, [...]);

  //...
});

The latestRequest returns the latest request which has matched a stubbed HTTP request. If there wasn't overlap, it will return undefined or the overlap from a previous test. Probably, another command is required to clean the latestRequest variable in the SW. However, the current behavior isn't a problem for me.

test('creates a user', async function() {
  let req = await helpers.latestRequest();

  assert.isOk(req);
  assert.equal(req.method, 'POST');

  assert.deepEqual(req.body, {
    email: 'user@example.org',
    name:  'John Doe'
  });
});

The sendMsgToSwHttpStubs is that kind of a private method which is used in communicating to the SW.

Wrap up

That's it. The solution is a simple and it is easy to adjust for edge cases that projects might have. The biggest benefit of this solution is that any HTTP requests can be stubbed, it might be requests to images or endpoints of an app or requests via Fetch API which Sinon cannot stub. I use this solution in my JS library. All 3 parts of the solution can be found in a test folder.