Testing web components
Tags: webcomponents, testing, puppeteer, tsx
Published: 2024-01-16 15:56:38

## The problem

Recently [one of my pet projects](https://gitlab.com/hesxenon/unstyled-web-components/-/tree/main) - a web component that mimics, localized and extends the default `<input>` tag - had gotten complex enough that I couldn't warrant manual testing for it anymore. So, naturally, I tried to setup a unit testing environment. JSDom supports custom elements, right? Well yeah, kinda. Problem is, my little component wants to integrate with [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) so plays nicely with forms. And JSDom hasn't implemented that at the time of writing. And with that I opted for puppeteer.

**So if you find yourself reading this article at a point in time where JSDom _has_ implemented `ElementInternals` I'd suggest to stop reading and try it with JSDom instead.**

## The basics

In a nutshell, all we have to do is startup a new page with puppeteer, fill it with some skeleton that includes our library and then add our web component to the body of that page, after which we can query for that element and assert it behaves and looks like we expect it to.

## The requirements

Since I don't exactly like writing html strings by hand and jsx is such a nice templating language, I've used [another library of mine](https://www.npmjs.com/package/tsx-to-html) to transform typed jsx into html strings.

So with a quick
```bash
npm install -D tsx-to-html puppeteer
```
we should have everything we need

## The setup

A simple function to return us a page with our content should be enough.

```tsx
// Utils/Test.tsx
import puppeteer from "puppeteer";
import { toHtml } from "tsx-to-html";
import * as Fs from "node:fs";

const lib = Fs.readFileSync("dist/index.js", "utf8");

const browser = await puppeteer.launch({ headless: "new" });

export const init = async (content: JSX.Element) => {
  const consoleMessages = [] as ConsoleMessage[];
  const page = Object.assign(await browser.newPage(), {
    console: { messages: consoleMessages },
  });

  page.on("console", async (msg) => {
    consoleMessages.push(msg);
    const args = await Promise.all(msg.args().map((a) => a.jsonValue()));
    console.log(...args);
  });

  page.on("pageerror", console.error);

  await page.setContent(
    toHtml(
      <html>
        <head>
          <script type="module">{lib}</script>
          <style>{css}</style>
        </head>
        <body>{content}</body>
      </html>,
    ),
  );

  return page;
};
```

All we're doing here is launch a new headless chrome, create a page in it, include our (preloaded) script (`type="module"` is necessary for me because I'm outputting ESM) and include the passed `content` in its body.

## The tests

```tsx
import { describe, it, expect } from "yourFavoriteTestrunner";
import { init } from "Utils/Test";

describe("uwc-input", () => {
  it("should have class pristine", async () => {
    const page = await init(<uwc-input></uwc-input>);
    const classes = await page.$eval("uwc-input", (input) =>
      Array.from(input.classList),
    );
    expect(classes).toContain("pristine");
  });
});
```

## The importance of types

Don't forget to include your custom element in the `HTMLElementTagNameMap` for some nice type safety like this

```typescript
class MyElem extends HTMLElement {}

declare global {
  type MyElem = (typeof MyElem)["prototype"];
  interface HTMLElementTagNameMap {
    "my-elem": MyElem
  }
}
```

## The conclusion

Initially pushing this setup away from me for fear of being too complicated I think it took me longer to write this blog post than to get started with this, so... start testing, will ya?