Having covered the architecture and implementation of our dynamic service worker application ‘Woz’ in the first part of this series, this post will cover what approach we have taken to writing tests for it.
Service Workers are a powerful tool, but once a malfunctioning Service Worker is installed and activated on a user’s browser, things can get messy. Moreover, Service Workers are notoriously difficult to debug, which can cause headaches and frustration. With this in mind, we knew that we had to invest a lot of care into our unit tests and functional testing suite to be able to push changes frequently and confidently.
If you haven’t read the first part of this series, it may be worth going back and reading it first as I will make references to it that may be unclear to you otherwise.
Kill Switch
First and foremost, we knew we needed to have a way of uninstalling a malfunctioning Service Worker. If you have developed a Service Worker for your application before, you know that this can be done via the browser’s Dev Tools. However, this is obviously not something you’d want to ask your users to do.
Instead, we needed a kill switch for whenever we realised we had shipped a broken Service Worker. From reading the first part of this series, you know that we generate an sw.js
file for whenever a request is made to '/:language-:country/sw.js'
. Sending back an sw.js
file only containing a console.log(“service worker has been disabled”)
statement when a request is made to that route meant that we’d be able to override a faulty sw.js
that is activated on a user’s browser. The actual kill switch we’d have to lever is just a disable: true
config field in our dynamic Service Worker application Woz. As long as this flag is set to true
, our application would override any existing Service Workers.
The entire route handler would look something like this:
Unit tests
In Woz, one single function is responsible for fetching all assets, routes, and third-party scripts that are supposed to be cached for a given locale. Once this information is fetched, it builds a huge string, which will become the actual sw.js
file:
But how do you test a function that creates a different, huge string for whatever config, assets and routes you pass to it? Given that template literals allow you to interpolate expressions, we were able to extract some of the logic into separate functions, that we could then inline into the template literal using the ${someFunction}
expression. On lines 10, 17 and 18 you can see function calls that, once called, are immediately invoked. As you might already be guessing, this means that these functions themselves will return a function that will be interpolated into the template literal to then be immediately invoked. Let’s take line 18 as an example: (${addRoutesForThirdPartyScripts(thirdPartyScripts, workbox)})();
As the function name and signature suggest, it takes an array of third party scripts and makes use of Workbox’s routing API to cache these scripts and serve requests to the script’s URLs from the cache:
While we could have just added this code straight into the template literal (in fact, that’s how we started), extracting it into functions not only makes our code more modular and the string-generating function less bloated, but it also allows us to unit test this! 🙌
Using Jest’s mocking functionality, we can check if our code correctly identifies whether the Workbox functionality we need is available and, if not, skips whatever we are trying to do here so that our Service Worker creation can continue without throwing any errors.
And, at last, we can test whether routes for our third-party scripts are registered successfully when Workbox is available:
Our unit tests for the other immediately invoked functions in our template literal ((${loadWorkbox(config, logger, importScripts, workbox)})();
and (${addAppShells(config, workbox)})();
) follow the same pattern.
Functional Tests
It is nice knowing what exactly the sw.js
we generate will look like and that we can undo any faulty Service Workers that we have shipped to our users. However, we still don’t have tests that check if the Service Worker will have the desired effects on our site. To test this, we have turned to Puppeteer, a high-level API to control the Chrome or Chromium browser.
We set up Puppeteer using the jest-puppeteer
preset in our jest.config.js
file. We have also added a jest-puppeteer.config.js
file with the following content:
We set the browser context to Incognito so that we can be sure that a previous service worker won’t affect the new window. The —-enable-features=NetworkService
is important, as it enables Service Worker on Puppeteer (still experimental by default). The —-disable-dev-shm-usage
and —-no-sandbox
were necessary so that we can run puppeteer in a Docker container as part of our CI pipeline. We set ignoreHTTPSError: true
to overcome Service Worker’s https requirement. And lastly, dumpio
is just there to get some more visibility while debugging.
We run our tests against a simple view that is served by Woz, the same express server that generates our Service Worker. The view is a handlebars template that looks like this:
where mrp-product.css
:
#invisible {
visibility: hidden;
}
and mrp-product.js
:
var description = document.getElementById('description');
description.textContent = 'This demo site is designed to serve and test service worker.';
console.log('mrp-product.js has loaded');
Having a working mock site in place, let’s take a look at our functional tests. We generally write tests based on one of two cases: one is for when our site’s assets are served via the network and one for when assets are served by our Service Worker. The first test we go through is for the scenario where we visit our page for the first time, i.e. when our Service Worker is registered but not activated yet and when assets should be served by the network. See the comments within the code snippet for explanations of each step:
The last it
block from the code snippet above is our personal highlight amongst the discoveries we’ve made while writing our functional testing suite. Here, we worked with PerformanceEntry objects (part of the Performance Web API), which are indirectly created by the browser when resources are loaded, e.g. images or JavaScript files. Using the PerformanceEntry type resource
, which implements the PerformanceResourceTiming interface, we can check whether an asset (in this case mrp-product.js
and mrp-product.css
) was served via the network and not by a Service Worker. Instances of the PerformanceResourceTiming interface have a workerStart
field, which is the number of milliseconds a web worker took to serve a resource. Given that our assets were served via the network and not by a worker, it should be 0, which we can easily assert against in our test.
The second part of our test checks what happens after reloading the page. We expect the Service Worker to be activated and assets to be served by Service Worker rather than the network. Again, please see the comments for explanations:
In this case, the workerStart
value will not be 0 as the assets will be served by Service Worker.
Conclusion
Implementing proper testing for our dynamic Service Worker generator Woz probably took longer than writing the application itself. However, we think that having this in place will pay off in the long-term as we will be able to develop and ship more PWA features without the headache and frustration and without fearing that we might plant a buggy Service Worker on our users’ browser.
I hope that you found this post helpful and that you’ll be able to use some of our testing techniques in your own Service Worker implementation. If you have any questions, feel free to reach out on Twitter @MaximilianAst.
The next part of this series will be about the impact that Service Worker has had on our performance metrics, which we hope to publish in early 2020. Stay tuned!