Testing Your Web Components

Recently we discovered the dom-testing-tools in a team in which I’m currently working in, and soon after we started applying its patterns we used it almost entirely for any kind of component testing. It’s a library that really allows you to focus on testing the user behaviour rather than the implementation of your components.

The tool is very easy to use with libraries like react and vue, yet when I tried to get started with it and web components it got slightly more difficult.

It needed some emergency tweeting to have a nice support for dom-testing-tools, but now, thanks to the help of - and with some fiddling around - it can be used.

Those times when you scream out on twitter to get what you want

 

This is how I set up my projects with jest if I want to build something with typescript, jest, dom-testing-tools, and my LitElement/pure-lit bundle.

tl;dr

If you would rather read the code, you can find a working demo repo here: https://github.com/MatthiasKainer/dom-testing-tools-web-components-todo-list

Setting up the project

Create a new folder, and run npm init to set up your javascript project, with jest as test command. As we want to run it as a typescript project, run npm install --save-dev typescript.

Create a tsconfig.json for your project in the current folder, mine usually looks like this

// ./tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "es2015",
    "lib": ["es2017", "dom", "dom.iterable"],
    "outDir": "build",
    "sourceMap": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
  },
  "include": ["src/**/*.ts"],
  "exclude": []
}

As we specified in this file that we will transpile only files in the src folder, create that next via mkdir src.

Setting up the test environment

To have all of it up and running, we will have to install “a few” dev dependencies. They can be clustered into three groups: jest, the dom-testing-library and the open-wc testing helpers.

Jest is our test runner and assertion helper. It comes with a lot of tools out of the box and doesn’t need a lot of configuration.

The dom-testing-library is a library that tries to help you write tests that see your code as the user would, thus allowing you to create more maintainable tests.

The testing helpers provide you with easy and convenient ways to add your custom elements to the screen.

npm install --save-dev \
    jest \
    @types/jest \
    ts-jest \
    @babel/preset-env \
    @testing-library/dom \
    @testing-library/jest-dom \
    @testing-library/user-event \
    testing-library__dom \
    @open-wc/testing-helpers

Next, we install up our dependencies for the components we are going to create. The web components will be created as LitElements, with pure-lit as a functional wrapper.

npm install lit-element \
  lit-element-state-decoupler \
  pure-lit

The next part is configuring jest. We will have to configure multiple things to have our setup (typescript, open-wc/lit-elements stack) running fine.

First, we are going to use "preset": "ts-jest/presets/js-with-babel" as a preset for jest. It handles the typescript transpilation, and allows us to also transpile es2017 javascript based in a babel.config.js.

The corresponding babel.config looks like this

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      { targets: { node: 'current' } },
    ],
  ],
};

Also, we will have to specify the node_modules that should not be transformed by jest and set it to the web-component parts.

Next, we need a setup file to register the test setup which registers the jest-dom matches. The setup would look like this

// ./src/testSetup.ts
import '@testing-library/jest-dom/extend-expect';

and the file is referenced via "setupFilesAfterEnv": [ "./src/testSetup.ts" ]

Last part is adding the regular expression to identify the tests. The complete jest config, therefore, looks like this:

{
    "preset": "ts-jest/presets/js-with-babel",
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$",
    "transformIgnorePatterns": [
      "node_modules/(?!(testing-library__dom|@open-wc|lit-html|lit-element|pure-lit|lit-element-state-decoupler)/)"
    ],
    "setupFilesAfterEnv": [
      "./src/testSetup.ts"
    ]
  }

That was quite a bit of setup. Let’s check if it is working at all. Create a new file todo-list.test.ts in the src folder.

// ./src/todo-list.test.ts
describe("todo-list", () => {
    it("should run an empty test", () => {
        expect(true).toBeTruthy()
    })
})

Run it via npm test. The result should look like this:

 PASS  src/todo-list.test.ts
  todo-list
    ✓ should run an empty test (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.411 s

Nice. Our test is running. Let’s extend it to something more useful.

The @open-wc/testing-helpers give a function fixture that allows us to add the component to the page.

The testing-library__dom exposes the screen object that we get from dom-testing-tools.

Using the two of them we can set up our first test.

import { screen } from "testing-library__dom";
import { fixture } from "@open-wc/testing-helpers";

describe("todo-list", () => {
  beforeEach(async () => {
    await fixture("<todo-list></todo-list>");
  });

  it("has a headline", () => {
    expect(screen.getByRole("heading")).toBeDefined();
  });
})

This test is verifying that we have a heading on the page, not more, not less.

If we run it we have our first failing test, because we don’t have a component yet.

 FAIL  src/todo-list.test.ts
  todo-list
    ✕ has a headline (331 ms)

  ● todo-list › has a headline
    TestingLibraryElementError: Unable to find an
      accessible element with the role "heading"

Let’s create the webcomponent to green the test. Create a new file todo-list.ts and add the following content:

// ./src/todo-list.ts
import { pureLit } from "pure-lit";
import { html } from "lit-element";

pureLit("todo-list", () => {
  return html`
    <h1>My Todo List</h1>
  `
});

This creates a custom element todo-list that we can now add to the page. We will have to import it to the test so that the registered component will be rendered:

+ import "./todo-list"
import { screen } from "testing-library__dom";
import { fixture } from "@open-wc/testing-helpers";

describe("todo-list", () => {
  beforeEach(async () => {
    await fixture("<todo-list></todo-list>");

When we run the test again it should be green.

 PASS  src/todo-list.test.ts
  todo-list
    ✓ has a headline (62 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.985 s

Detour: about toBeInTheDocument . When using jest-dom, usually you would test if the element is in the document. But here’s the thing: With shadow dom, it’s not. If we look at the generated html, we can see that the shadow dom element is created as object inside the regular dom, like this:

    <body>
      <div>
        <!---->
        RuntimeRepresentation {
          "_changedProperties": Map {},
          "_enableUpdatingResolver": undefined,
          "_instanceProperties": undefined,
          "_reflectingProperties": undefined,
          "_updatePromise": Promise {},
          "_updateState": 1,
          "renderRoot": ShadowRoot {
            Symbol(SameObject caches): Object {
              "children": HTMLCollection [
                <h1>
                  My Todo List
                </h1>,
              ],
            },
          },
          Symbol(SameObject caches): Object {
            "childNodes": NodeList [],
            "children": HTMLCollection [],
          },
        }
        <!---->
      </div>
    </body>

The h1 is therefore not part of the document, but a child of the contained object.

Adding behaviours

Now that we can test our custom element, we would want to get an element to add todos. Let’s write the test for it first:

  it("has an input to add todos", () => {
    expect(screen.getByRole("textbox", { name: /new todo/i })).toBeDefined();
  });

Let’s run it to see that it is indeed red.

 FAIL  src/todo-list.test.ts
  todo-list
    ✓ has a headline (64 ms)
    ✕ has an input to add todos (224 ms)

  ● todo-list › has an input to add todos
    TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name `/new todo/i`

So let’s start with the implementation.

A first thing we would want is an input field that triggers the change event when an item has been added and confirmed with the enter key. To test this, we create a new component with our tests set up slightly different. We will be using the user-events from the testing tools to act as a user, and we will be listening to

// ./src/submitting-input.test.ts
describe("submitting-input", () => {
  const onSubmit = jest.fn()

  beforeEach(async () => {
    await fixture(html`<submitting-input
      @submit=${(e: CustomEvent) => onSubmit(e.detail)}
      label="example"></submitting-input>`);
  })

  it("doesn't submit empty text", async () => {
    await userEvent.type(
      screen.getByRole("textbox", { name: /example/i }),
      "{enter}"
    );
    expect(onSubmit).toBeCalledWith("a new entry")
  });

  it("submits valid text correctly", async () => {
    await userEvent.type(
      screen.getByRole("textbox", { name: /example/i }),
      "a new entry{enter}"
    );
    expect(onSubmit).toBeCalledWith("a new entry")
  });
})

Run the tests, they should be red.

Building such an element in pure-lit is relatively easy - basically just a wrapper around the input element that dispatches a custom event “submit” when the key pressed is enter. As an example, this component could look like this:

// ./src/submitting-input.ts
pureLit("submitting-input",
  (el: LitElementWithProps<{ label: string }>) =>
    html`<input
      type="text"
      aria-label=${el.label}
      @keypress=${(e: KeyboardEvent) => {
        const element = e.target as HTMLInputElement;
        if (element.value !== "" && e.key === "Enter") {
          el.dispatchEvent(new CustomEvent("submit", { detail: element.value }));
        }
      }}
    />`
, {defaults: {label: "input"}});

Note that the test itself is completely independent of the implementation itself, as what is tested is really the user’s behaviour!

Let’s add the input to our component. We won’t care about handling the event just yet, this we will do once we have the list.

// ./src/todo-list.ts
import { pureLit } from "pure-lit";
import { html } from "lit-element";

pureLit("todo-list", () => {
  return html`
    <h1>My Todo List</h1>
    <submitting-input
      label="add todo"></submitting-input>
  `
});

The next thing we want to see is the list of todos after we added it. Let’s write the test for it next. The textbox we can access like before, by selecting the name of the role - in our case add todo. Then, just like before, we send some keys and click enter.

// ./src/todo-list.test.ts
  const input = () => screen.getByRole("textbox", { name: /add todo/i })

  it("adds a todo", async () => {
    await userEvent.type(input(), "a new entry{enter}");
    expect(screen.getAllByRole("listitem")[0]).toContainHTML(">a new entry<");
  });

  it("adds multiple todos in order", async () => {
    await userEvent.type(input(), "first{enter}");
    await userEvent.type(input(), "another{enter}");
    expect(screen.getAllByRole("listitem")[0]).toContainHTML(">first<");
    expect(screen.getAllByRole("listitem")[1]).toContainHTML(">another<");
  });

Note the > < around the html snippets? Those ensure that this snippet is really all the text there is, as they include the surrounding html elements. As the toContainText would match partial entries, this can lead to false positives.

Run the tests and ensure they are red. For implementation we will have to add state handling. With pure-lit, the natural choice is the lit-element-state-decoupler hook. With that, the code is pretty straightforward and would look somewhat like that:

// ./src/todo-list.ts

pureLit("todo-list", (el: LitElement) => {
  const {getState, publish} = useState(el, [] as string[])
  return html`
    <h1>My Todo List</h1>
    <submitting-input
      label="add todo"
      @submit=${(e: CustomEvent) => publish([...getState(), e.detail])}></submitting-input>
    <ul>
      ${getState().map((item) => html`<li>${item}</li>`)}
    </ul>
  `
});

If you run the tests, the first test will pass, the second will fail, claiming that <li>firstanother</li> does not contain >another<. The text from the first item was not removed from the input after we submitted. Let’s add a test for this Acceptance Criteria in our submitting-input form:

// ./src/submitting-input.test.ts
  it("clears the input after submitting", async () => {
    await userEvent.type(
      input(),
      "a new entry{enter}"
    );
    expect(input()).toHaveValue("")
  })

And make the test green via

// ./src/submitting-input.ts
if (element.value !== "" && e.key === "Enter") {
  el.dispatchEvent(new CustomEvent("submit", { detail: element.value }));
+  element.value = "";
}

There. We have created a whole fully functional simple todo list app without opening it once in the browser, knowing it will work! Isn’t that great?

Packaging it

Obviously, at some point, we might want to ship it to the customer.

My preferred way at the moment is a rollup, which we set up via running

npm i --save-dev rollup \
  rollup-plugin-terser \
  rollup-plugin-node-resolve \
  @rollup/plugin-replace \
  @rollup/plugin-commonjs \
  rollup-plugin-typescript2 \
  rollup-plugin-filesize

and creating a rollup config like this

// rollup.config.js

import { terser } from "rollup-plugin-terser";
import resolve from "rollup-plugin-node-resolve";
import replace from "@rollup/plugin-replace";
import commonjs from "@rollup/plugin-commonjs";
import typescript from "rollup-plugin-typescript2";
import filesize from "rollup-plugin-filesize";

export default {
  input: `./src/todo-list.ts`,
  output: {
    file: `dist/app.js`,
    format: "esm",
  },
  plugins: [
    typescript(),
    replace({ "Reflect.decorate": "undefined" }),
    commonjs(),
    resolve(),
    terser({
      module: true,
      warnings: true,
      mangle: {
        properties: {
          regex: /^__/,
        },
      },
    }),
    filesize({
      showBrotliSize: true,
    }),
  ],
};

Then to the package JSON add

  "scripts": {
+    "build": "rollup -c",
    "test": "jest"
  },

and run npm run build.

All that remains is adding an HTML like this

<!DOCTYPE html>

<html>
  <head>
    <meta charset="utf-8" />
    <title>Todo List</title>
    <script type="module" src="/dist/app.js"></script>
  </head>
  <body>
    <todo-list>
      <p>Loading todo list</p>
    </todo-list>
  </body>
</html>

And open the file in your browser. As the test predicted, the application works just fine.