Link Search Menu Expand Document

Module 10: Unit Testing Web Components

JavaScript was originally created as a scripting language, intended to provide a simple “glue code” between components written in another language such as Java. This has meant that, historically, JavaScript has often been overlooked when it comes to concepts like unit testing.

With the rise of nodeJS as a server-side application platform, we’ve seen a huge improvement in the quality and range of tools available for creating and testing JavaScript applications. In this section, we’re going to meet one of those tools, an open-source testing framework for JavaScript applications created by Facebook.

Adding Jest to our project

For this section of the workshop, we’ll use the same counter example as the previous few sections. If you need to, you can download a copy of the application code here:

Download the code: counter-with-snowpack-and-sass.zip

⚠ You’ll need to run yarn install after downloading the code, to install the various packages and dependencies we used in the last section.

Installing Jest

Jest is available as an npm package, so we can install it using yarn:

yarn add --dev jest

We’ll need to edit our package.json file to add a new entry to the scripts section:

"scripts": {
    "start": "snowpack dev",
+    "test" : "jest"
  },

Once that’s done, we can run jest by typing:

yarn test

and we’ll get a result something like:

yarn run v1.22.5
$ jest
No tests found, exiting with code 1
Run with `--passWithNoTests` to exit with code 0
In D:\jest
  6 files checked.
  testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches
  testPathIgnorePatterns: \\node_modules\\ - 6 matches
  testRegex:  - 0 matches
Pattern:  - 0 matches
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

The key here is the first line:

No tests found, exiting with code 1

– which isn’t really a surprise, because we haven’t written any tests. So, let’s write one.

Create a folder called tests, and add a new file to it called example.test.js. A Jest test looks like this:

// example.test.js
test('1 = 1', () => {
    expect(1).toEqual(1);
});

Run your tests with

$ yarn test

or to only run tests from a specific file, use:

$ yarn test tests/example.test.js

⚠ Note that even on Windows, yarn requires a forward-slash / path separator when specifying paths to tests like this.

We’re expecting our output here to look something like this:

yarn run v1.22.5
$ jest
 PASS  tests/example.test.js
  √ 1 = 1 (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.348 s, estimated 1 s
Ran all test suites.
Done in 1.08s.

So far, so good. Now, let’s start writing tests for our counter application. Here’s a Jest test for one of our modules:

// tests/counting-engine.test.js
import CountingEngine from '../counting-engine.js';

describe('Counting engine initialises', () => {
    describe('with invalid initial value ', () => {
        const cases = [
            "foo", NaN, null, new Date()
        ];
        test.each(cases)('when %p is provided', value => {
            let engine = new CountingEngine(value);
            expect(engine.initialValue).toBe(0);
        });
    });
    describe('with valid initial value ', () => {
        const cases = [
            0, 1, 5, 99, 9999999999
        ];
        test.each(cases)('when %p is provided', value => {
            let engine = new CountingEngine(value);
            expect(engine.initialValue).toBe(value);
        });
    });
});

We’re going to import our CountingEngine module, initialise a new engine with the initial value 5, and then verify that it’s initialised with the correct value.

Run our test with yarn test and…

image-20210426170015029

A wall of error messages. Not a great start.

But, hold on… take a close look at this section:

Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/en/ecmascript-modules for how to enable it.
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

Absolutely right we’re trying to use ECMAScript Modules - they’re one of the core technologies that make web components possible!

If we follow that link to https://jestjs.io/docs/ecmascript-modules, we’ll get a lot of scary-looking disclaimers about how Jest support for ECMAScript Modules (ESM) is experimental:

“Note that due to its experimental nature there are many bugs and missing features in Jest’s implementation, both known and unknown.”

Now, I don’t know about you, but I don’t really like the idea of verifying the quality of my own code with a testing framework that has “many bugs and missing features […] both known and unknown”.

However, there’s another way to do what we need.

Babel: a transpiler for JavaScript

Babel is an open-source project that translates modern JavaScript into old JavaScript. Babel was originally designed so that we could use modern JS features like array methods and classes in client-side code, and then run this through Babel to convert it into JavaScript that would run in older browsers like Internet Explorer.

Modern JavaScript has lots of very cool features like ES modules, but the vast majority of these are aimed to make developers’ lives easier. There is very little in modern JavaScript that is genuinely impossible to implement in older versions of the language; it’s just that modern JS gives us more powerful language keywords, cleaner syntax, and more expressive abstractions.

Think of it this way: imagine trying to translate modern English so that somebody from the 1980s would understand it. Someone from 1985 would have no idea what an “iPhone” was, so you’d have to break everything down: “Pick up the shiny black rectangle. Press the smooth black glass surface with your finger. A screen will light up showing a grid of small pictures. Find the picture that looks like a little white bird on a blue rectangle. Press that picture with your finger.”

That’s sort of what Babel does – it figures out how to translate modern JavaScript keywords into functions and methods that can be run on older platforms.

So far in this workshop, we’ve been targeting modern browsers; there’s no way that tools like Babel can add support for things like custom HTML elements to older browsers, and over 90% of browsers have native support for all the features we’ve been using.

But… we can use Babel to translate our modern shiny JavaScript code, including all our ES modules, into a slightly older dialect of JavaScript that works with Jest.

Installing Babel

Babel is available as an npm package, so we’ll install it using yarn. We’re actually installing four packages here:

  • @babel/core is the core Babel transpiler (docs)
  • @babel/plugin-transform-runtime is the plugin that converts modern JS to old-school JS at runtime (docs)
  • @babel/preset-env allows us to configure Babel by specifying what runtime environment we’re targeting (including Jest) (docs)
  • babel-jest is a plugin for Jest that enables Babel support (GitHub)
yarn add --dev babel-jest @babel/core @babel/plugin-transform-runtime @babel/preset-env

We also need to add a new file to the root of our project called .babelrc:

{
    "presets": ["@babel/preset-env"],
    "plugins": ["@babel/plugin-transform-runtime"]
}

Now, when type

yarn test

We should get a successful test run:

D:\Dropbox\Workshops\github\jsweb\examples\unit-testing\counter-with-tests>yarn test
yarn run v1.22.5
$ jest
  PASS  tests/counting-engine.test.js
 Counting engine initialises with correct default value (2 ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.383 s
Ran all test suites.
Done in 3.49s.

Test Strategy for Web Components

Now that we have jest installed and working, we can start adding test coverage for our application logic. This is where the architecture we developed earlier in the workshop is going to pay off in a big way.

Testing Application Logic

To test the core application logic, we can create tests that hook directly into our business methods and verify the results. These tests are relatively simple to create and maintain, because they don’t involve any web or UX considerations – we’re only testing the logic itself.

// tests/counting-engine.test.js
import CountingEngine from '../counting-engine.js';

describe('Counting engine initialises', () => {
    describe('with invalid initial value ', () => {
        const cases = [
            "foo", NaN, null, new Date()
        ];
        test.each(cases)('when %p is provided', value => {
            let engine = new CountingEngine(value);
            expect(engine.initialValue).toBe(0);
        });
    });
    describe('with valid initial value ', () => {
        const cases = [
            0, 1, 5, 99, 9999999999
        ];
        test.each(cases)('when %p is provided', value => {
            let engine = new CountingEngine(value);
            expect(engine.initialValue).toBe(value);
        });
    });
});

Testing Event Handlers

Separately, we can verify that our web component is calling the correct methods on our counting engine when particular events fire in the browser. To do this, we’re going to create an instance of our counting engine, and then replace the appropriate method with a mock - a replacement method provided by Jest that we can use to verify that a method has been called.

// tests/counter.test.js
import MyCounterElement from '../counter.js';

describe('counting engine handles keyboard events', () => {
    test('up arrow increments engine', () => {
        // Instantiate a new component and run the connectedCallback() method
        let counter = new MyCounterElement();        
        counter.connectedCallback();

        // Replace the counter engine's increment method with a Jest mock
        counter.engine.increment = jest.fn();

        // Simulate a keypress ArrowUp event
        let fakeEvent = { code: "ArrowUp" };
        counter.handleKeydown(fakeEvent);

        // Verify that the counter engine's increment method was called exactly once.
        expect(counter.engine.increment).toHaveBeenCalledTimes(1);
    });
});

Testing the Renderer

Finally, we can write tests that verify that the state of our counting engine is rendered into the document root correctly. Jest’s default configuration uses a library called JSDOM, which simulates the browser DOM, meaning we can call methods like document.createElement and querySelector within our tests.

// tests/renderer.test.js
import CountingEngine from "../counting-engine";
import Renderer from "../renderer";

test('renderer draws correct value', () => {
    
    // Create an HTML element to act as the root element for our renderer
    let root = document.createElement('div');
    let renderer = new Renderer(root);

    // Instantiate a new counting engine
    let engine = new CountingEngine(5);
    // Tell the renderer to render the engine into the root element
    renderer.render(engine);

    // Retrieve a reference to the span element inside the rendered DOM
    let span = root.querySelector("span");

    // Verify that the span contains the value of the counter    
    expect(span.innerText).toBe(5);

});

Unit Testing: Review and Recap

  • We can use Jest, an open-source unit testing framework for JavaScript, to test our web components
  • Jest doesn’t yet ship with native support for ES modules, so we need to use Babel to translate our modern JS code into something that Jest can understand
  • Using the model/view/controller pattern in our application design allows us to test the various parts of our application independently.
  • We can simulate browser events by creating JavaScript objects that expose the properties and values we need
  • We can simulate DOM interactions by using the JSDOM library expose by Jest
  • The Jest project homepage is at https://jestjs.io/
  • The Babel project homepage is at https://babeljs.io/