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

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).

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.

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.

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.