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:
- a container for keeping stubs
- the SW intercepting requests
- helpers for interacting with the SW in tests
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:
stubRequest
sets stubs.
{
command: 'stubRequest',
details: {
method: 'POST',
url: /\/countries/,
response: {countries: []}
}
}
latestRequest
returns the latest stubbed request. Unfortunately, we cannot record the real latest request, because it might be anything, for example, a request to an image.
{
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.