DEV Community

hesxenon
hesxenon

Posted on

Testing web components

The problem

Recently one of my pet projects - 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 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 to transform typed jsx into html strings.

So with a quick

npm install -D tsx-to-html puppeteer
Enter fullscreen mode Exit fullscreen mode

we should have everything we need

The setup

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

// 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;
};
Enter fullscreen mode Exit fullscreen mode

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

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

The importance of types

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

class MyElem extends HTMLElement {}

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

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?

Top comments (0)