🍺

Webコンポーネントをユニットテストする話

2021/11/13に公開

はじめに

そもそもWebコンポーネントって?

https://developer.mozilla.org/ja/docs/Web/Web_Components

Web Components は、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。

新しいHTMLのタグを自作できる仕組みと考えれば想像しやすいのではないでしょうか?

個人的に最も恩恵の大きいのは「Shadow DOM」です。
100%!ではないのですが、Webコンポーネント外からのスタイルシートの影響を殆ど受けず(知識があれば一応すり抜けるセレクタは書ける)、またWebコンポーネント内で定義したスタイルシートはWebコンポーネント外へ影響を与えません。

ちなみにWebコンポーネント内で別のWebコンポーネントタグを含めることは可能で、各々がLight(表)とShadow(裏)な関係になります。どのくらい入れ子にできるかは知りませんが、多分端末の性能依存な気がしてます。

Webコンポーネントのイマイチな点

スタイルシートと違い、JavaScriptには表裏が存在しません。
すなわち外部の状態に影響を受ける可能性があります。
したがって「外部サイトに自由に読み込ませて自サイトへのログイン画面を提供しちゃおう」なんて用途の場合は注意が必要かもしれません。

Shadow DOMを構成する際にHTMLとして簡潔にlintが使える書き方が難しい。

  • 表の方にtemplate タグを利用して書く方法は、HTMLとして簡潔にlintが使えるかもしれないけど、そもそもコンポーネントとして完結してなくて不満
  • ``(バッククォートによるテンプレートリテラル表現)はコンポーネントとして完結出来るし一応HTMLが書けるけど、単なるjsの文字列である扱いの為、lint等が使えるかというとそういうのは知らない
    →この問題に関しては、わたしはtsで作ってHTMLファイルをrequire(html-loader)することで一旦解決というか妥協しています。

前準備

こんな感じのDockerfileを書いてVolumeだけ別途付けて使ってます。
情報が多そうだったのが理由で単体テストツールとしては「jest」を選択しました。

Dockerfile
FROM node:12.18-alpine
ENV NODE_ENV production
ENV NODE_PATH /usr/local/lib/node_modules
RUN apk update && apk add git
WORKDIR /usr/src/app
RUN npm install -g \
    npm husky typescript \
    webpack webpack-cli typescript ts-loader webpack-dev-server copy-webpack-plugin html-webpack-plugin ts-node html-loader @types/node \
    jest ts-jest @types/jest html-loader-jest

VSCodeのRemote-Containerを利用してこのコンテナの中で作業してます。

普通にdevオプションで実行してもよいのですが、
linkにしておくとソースフォルダにファイルが増えないので気に入っています

npm link ts-loader html-loader @types/node @types/jest

jest.config.jsを以下の内容で用意しました。
一部書き換えるつもりでデフォルトをコピーしてもってきてますが、
今のところ変える必要が無い事が分かったのでそのままにしてあります。

WebComponentをhtml-loaderの使用を前提として作成しているため、html-loader-jestの設定は必須となります。

jest.config.js
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  testMatch: [
    "**/__tests__/**/*.[jt]s?(x)",
    "**/?(*.)+(spec|test).[jt]s?(x)",
  ],
  "transform": {
    "^.+\\.html?$": "html-loader-jest",
  }
};

参考:当記事執筆時に作成していたもののtreeは以下の状態になっていました。

# tree
.
├── README.md
├── dist
│   ├── index.html
│   └── wc-poster.js
├── jest.config.js
├── node_modules
│   ├── @types
│   │   ├── jest -> ../../../../../usr/local/lib/node_modules/@types/jest
│   │   └── node -> ../../../../../usr/local/lib/node_modules/@types/node
│   ├── html-loader -> ../../../../usr/local/lib/node_modules/html-loader
│   └── ts-loader -> ../../../../usr/local/lib/node_modules/ts-loader
├── package.json
├── src
│   ├── html
│   │   └── index.html
│   └── ts
│       ├── class
│       │   └── Poster.ts
│       ├── main.ts
│       └── template
│           └── Poster.html
├── test
│   ├── dummy
│   └── ts
│       └── class
│           └── Poster.test.ts
├── tsconfig.json
├── webpack.config.dev.js
└── webpack.config.js

ソースとテストのファイルのディレクトリを分けているので、名前を被せても動きそうですが、この辺は好みの問題もあるかもしれません。私はテストコードはテストコードと分かる名前が好きです。

テスト実施方法

実は難しく考えすぎていたのですが、(jsdom設定だと?)documentが使えるので、

document.body.innerHTML = `<web-component></web-component>`

let wc = document.createElement('web-component');

でWebコンポーネントのオブジェクトが作成されてコンストラクタが動くし、オブジェクトを操作することも可能です。

addEventListenerで追加したイベントに関しては
dispatchEventを呼び出すことでカバレッジが上昇することも確認できました。

内部でPromiseを使用するケースはカバレッジが反応しなかった為、Promise生成だけ別のメソッドに切り出すなどの工夫が必要そうです。

Private問題

実はJavaScriptのクラスにもアクセスレベル、具体的にはプライベートの概念が存在します

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_class_fields

JavaScriptのクラスでは、ハッシュ#を名前の先頭に使用するとその変数/メソッドがプライベート化します。
このプライベートメソッドを単体でテストしたいと思ってしまったが最後、泥沼です。
TypeScriptのクラスであれば、

(obj as any).privatemethod();
obj['privatemethod']()

などの逃げ道が一応あるのですが、

(obj as any).#method();
obj['#method']()

はエラーします。調べたりObject.definePropertyでごにょごにょすることを試みましたが上手く行かず。

最終的に
TypeScriptの流儀に則り(ハッシュ#は使用せず)privateで定義することとしました。

html-loader問題

必須ではないですが、前述の通り、私はテンプレートをhtmlに書きたくて、それをhtml-loaderで最終的に埋め込むという形で実現しています。

let template = require("../template/Poster.html").default

という書き方でWebpackの方は上手く行っているのに、jestではundefinedが発生するという事態が発生しました。

どうもjestで動作した際は

require("../template/Poster.html")

の時点でstringとして取得出来ていて、defaultの呼び出しが不要だったようです。

両者の実行結果に差異があるのは気持ち悪いのですが、一旦以下のようにして逃げました。

let template = require("../template/Poster.html");
if (typeof template !== 'string') {
    template = template.default;
}

カバレッジにも影響するので最終的にはrequireの挙動を揃えたいところですが、どっちに揃うのが正しいんでしょう?

fetch問題

もはやWebコンポーネントとは無関係ですが、おそらく別記事にする事はしないのでここに残しておきます。

fetch APIはNodeでは無い為、テスト時にエラーします。

検索すると「node-fetch」で解決していることが多いのですが、
どうにもエラーが止まらず、「cross-fetch」で解決しました。

参考

https://github.com/testing-library/jest-dom/issues/340

https://stackoverflow.com/questions/57478484/how-to-test-custom-web-component-with-jest

まとめ

Webコンポーネントはブラウザで表示するものなので、
Selenium系でテストした方が堅実な気もしてましたが
カバレッジが簡単に分かるようになるjest便利というか楽しいですね!

お疲れさまでした。

Discussion