Testing Web component with Jest and Lit element


We all know that testing is critical for any software product and JS landscape offers a lot of tools to perform this task. One of the most popular ones is Jest which is famous for its simplicity.

However, while working recently on project that involves web components it was really hard to find examples of anyone using Jest for this purpose. Even open-wc recommends using Karma for testing your web components.

Karma configuration can be daunting for both junior or senior devs and it was surprising that no one was using Jest. I've set out on a mission to find out why...

TLDR It is possible to test web components with Jest. Don't use JSDOM but run your tests in a browser environment instead.


But first things first, let's set up our project and see the component which we are going to test. Nothing fancy today, just a one binding, awesome button created with LitElement and Typescript.


npm install lit-element && npm install -D typescript
//button.ts
import {html, customElement, LitElement, property} from "lit-element";

@customElement('awesome-button')
export class Button extends LitElement {

    @property()
    buttonText = '';

    render() {
        return html`<button id="custom-button"
            @click="${() => {}}">${this.buttonText}</button>`;
    }
}

We are going to use Webpack to bundle our code and then use it during our test run, so let's set up that as well.


npm install -D webpack webpack-cli ts-loader clean-webpack-plugin
//webpack.config.js
const path = require('path');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

module.exports = {
    mode: 'development',
    entry: './index.ts',
    module: {
        rules: [
            {
                test: /\.ts?$/,
                use: 'ts-loader',
                exclude: /node_modules/,
            },
        ],
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js'],
    },
    plugins: [
        new CleanWebpackPlugin()
    ],
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
};

Our last bit before we jump into testing is to install and set up Jest.


npm install -D jest @types/jest ts-jest jest-environment-jsdom-sixteen

You might be wondering why we've installed jest-environment-jsdom-sixteen? Jest runs in Node environment and for this Jest leverages JSDOM to run your unit tests. The goal of JSDOM is to emulate a browser within Node for testing purposes.

As per this Github issue, Web components support was added in the recent version of JSDOM and this is why we need to install v16 because Jest comes with v14 by default.

Our Jest config looks like this. Before the tests, Webpack will bundle our files and they will be picked up by Jest.


//jest.config.js
module.exports = {
    preset: 'ts-jest',
    testEnvironment: 'jest-environment-jsdom-sixteen',
    setupFiles: ['./dist/main.js']
};

And finally we get to our test! We want to keep it simple and test if our binding works.


//button.spec.ts
import {LitElement} from 'lit-element';

describe('awesome-button', () => {

    const AWESOME_BUTTON_TAG = 'awesome-button';

    it('displays button text', async () => {
        const dummyText = 'Web components & JSDOM';
        const buttonElement = window.document.createElement(AWESOME_BUTTON_TAG) as LitElement;
        buttonElement.setAttribute('buttonText', dummyText);
        window.document.body.appendChild(buttonElement);
        await buttonElement.updateComplete;

        const renderedText = window.document.body.getElementsByTagName(AWESOME_BUTTON_TAG)[0].shadowRoot.innerHTML;

        expect(renderedText.indexOf(dummyText)).not.toBe(-1);
    })
});

It fails? Running exactly the same code in a real browser will produce a valid result. Why is that?


 TypeError: Cannot read property 'innerHTML' of null

      12 |         await buttonElement.updateComplete;
      13 |
    > 14 |         const renderedText = window.document.body.getElementsByTagName(AWESOME_BUTTON_TAG)[0].shadowRoot.innerHTML;
         |                                                                                                         ^
      15 |
      16 |         expect(renderedText.indexOf(dummyText)).not.toBe(-1);
      17 |     })

      at button.spec.ts:14:105

After fiddling around, I've realized that JSDOM is simply not ready for this. Even though they support Web components, APIs for Shadow DOM and some other features from web component spec are not implemented (e.g. shadowRoot in our case). However, there is a trick which produces a semi-successful result. If we go back to our component and disable Shadow DOM, JSDOM is able to "see" our component.


//add this snippet to button.ts
createRenderRoot() {
    return this;
}

Patch up our test and use textContent instead of shadowRoot.


//button.spec.ts
const renderedText = window.document.body.getElementsByTagName(AWESOME_BUTTON_TAG)[0].textContent;

expect(renderedText.indexOf(dummyText)).not.toBe(-1);

We are in the green zone! ✔️

Why do I say this is semi-successful? First thing is that we don't want to disable Shadow DOM because it defeats the purpose of using Web components as this is one of the main features.

Second, we get textContent as a string. Do you really want to parse and assert on a string? What happens when you have a component with a lot of nested HTML elements? This approach just leads down to a rabbit hole...


Mission continues

Okay, now that we know why JSDOM is not an option, what other choices we have? Can we somehow run our unit tests in a real browser?

Maybe the first thing that comes to your mind would be Puppeteer, however this is not a scalable solution as it is more suited for E2E tests. In order to test your component, you would need to render it on some page, write additional logic that would navigate to this page and then do assertions.

Albeit possible, this requires a lot of setup and one of the main criteria for unit tests is that they are small and should provide rapid feedback.

Which leaves with us our last option - Electron JS! Electron is essentially a stripped off version of the Chromium browser which is adapted to run in Node environment.

The community also came up with a runner for Jest, so let's install this.


npm install -D electron jest-electron

We need to adapt our Jest config to use Electron as our test environment and runner.


//jest.config.js
module.exports = {
    preset: 'ts-jest',
    runner: 'jest-electron/runner',
    testEnvironment: 'jest-electron/environment',
    setupFiles: ['./dist/main.js'],
};

Let's extend our test. We will focus on testing our attribute binding but also some button interaction.


//button.spec.ts
import {LitElement} from 'lit-element';

describe('awesome-button', () => {

    const AWESOME_BUTTON_TAG = 'awesome-button';
    const ELEMENT_ID = 'custom-button';
    let buttonElement: LitElement;

    const getShadowRoot = (tagName: string): ShadowRoot => {
        return document.body.getElementsByTagName(tagName)[0].shadowRoot;
    }

    beforeEach(() => {
        buttonElement = window.document.createElement(AWESOME_BUTTON_TAG) as LitElement;
        document.body.appendChild(buttonElement);
    });

    afterEach(() => {
       document.body.getElementsByTagName(AWESOME_BUTTON_TAG)[0].remove();
    });

    it('displays button text', async () => {
        const dummyText = 'Web components & Jest with Electron';
        buttonElement.setAttribute('buttonText', dummyText);
        await buttonElement.updateComplete;

        const renderedText = getShadowRoot(AWESOME_BUTTON_TAG).getElementById(ELEMENT_ID).innerText;

        expect(renderedText).toEqual(dummyText);
    });
    it('handles clicks', async () => {
        const mockClickFunction = jest.fn();
        buttonElement.addEventListener('click', () => {mockClickFunction()});

        getShadowRoot(AWESOME_BUTTON_TAG).getElementById(ELEMENT_ID).click();
        getShadowRoot(AWESOME_BUTTON_TAG).getElementById(ELEMENT_ID).click();

        expect(mockClickFunction).toHaveBeenCalledTimes(2);
    });
});

npm run build && npm test
PASS  ./button.spec.ts

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        6.25s
Ran all test suites.

Finally! We have reached our goal! 😁 Our unit tests were running inside the browser where we can use all features from the Web components spec.

Few things to note:

All examples from this blog can be found on Github.


Conclusion

After this we can conclude that the only feasible way to unit test your web components is to run them in a real browser environment. It's not important if it is Karma or Jest, but at least we know that both support this option. Alternatives (like JSDOM) are not there yet.

Happy unit testing!😄