はじめに
こんにちは!Frontendチームの薄羽です。
イタンジのデザインシステムはReactコンポーネントになっており、Storybookで管理されています。1年前の記事でPlay functionでインタラクションテストを行っていると紹介しましたが、現在はPlaywrightを使ってテストを行っています(実は1年前から移行中でした)!
Play functionの問題点
<DropZone>(ファイルをドラッグ&ドロップできるコンポーネント)のテストで storybook/test の userEvent.upload() を使用した後、コンポーネント内部の input.files が書き換えられなくなるということが起きました。StorybookのPlay function(storybook/test)は @testing-library/dom や @testing-library/user-event の関数を内部で使用しています。そして、userEvent.upload() は setFiles() を呼んでいます。
setFiles() では Object.defineProperties() により files が書き換えられており、getterのみが定義され、setterは定義されていません。そのため、userEvent.upload() を呼ぶと要素の files を書き換えられなくなります。
また、Playwrightには FileChooser というものがあり、ファイル選択をシミュレートできますが、Play functionやTesting Libraryにはそのような実装はありません。
上記以外にもPlay functionがブラウザの挙動を再現できないことがあり、ほとんどはPlay functionで自動でテストできるものの、一部のコンポーネントの一部の機能については手動でテストしなければならなくなっていました。
この構成を考えていた時期にちょうどプロダクトにE2Eを導入しようとしており、Playwrightを触り始めていたのですが、Auto-waitingがPlay functionに無いのもしんどかったです(Auto-waitingっぽく動作する storybook/test を書いてみたりもしましたが、Playwrightに比べると書き味はあまり良くなかったです)。
Vitest addonの登場
Playwrightへの移行を考えているときに、StorybookのVitest addonが登場しました。Browser Modeでも実行できるため、Vitest addonに乗り換えれば諸々解決か…?と思ったのですが、イタンジの環境ではそうもいかなかったです。
ブログでも紹介したのですが、VRTのためにPlay function後にスクリーンショットを撮っています。検討時点では、Vitest addonの afterEach でスクリーンショットが撮れなかったため、我々の環境には合いませんでした。あと、Vitest addonだとStorybook上でテストを実行できるみたいな機能もありましたが、これとコマンドで実行したときの結果が異なり、少なくとも検討時点ではVitest addonに移行するのは微妙でした。
Playwrightへの移行
そこで、
- Storybook: テスト対象のコンポーネントを描画するストーリー
- Playwright: そのストーリーにアクセスするspec
という構成を思いつきました。
具体例
たとえば、<Button> コンポーネントの onClick についてテストしたいとします。<Button> コンポーネントは src/components/Button に定義されています。
src/components/Button/PlayOnClick.stories.tsx に以下のストーリーを定義します(#utils からexportされている関数の説明は後述します。また、テストの title を書くところなどが本当はありますが、その辺は省略しています)。
import { usePlayParameters } from "#utils"; export const PlayOnClick = meta.story({ parameters: { _play_: { label: crypto.randomUUID(), onClick: crypto.randomUUID() } }, render: () => { const parameters = usePlayParameters(PlayOnClick); return ( <Button onClick={() => { console.log(parameters.onClick); }}> {parameters.label} </Button> ); } });
次に、__tests__/playwright/components/Button.spec.ts に以下のspecを定義します。
import { getPlayParameters, test, waitForConsoleLog } from "#utils"; import type { PlayOnClick } from "../../../src/components/Button/PlayOnClick.stories"; test("PlayOnClick", async ({ page }) => { const parameters = await getPlayParameters<typeof PlayOnClick>(page); await waitForConsoleLog(page, async () => { await page.getByRole("button", { name: parameters.label }).click(); return parameters.onClick; }); });
これで、pnpm test:playwright を実行するとPlaywrightがPlayOnClickストーリーにアクセスし、specに記載したテストが実行されます。
解説
usePlayParameters()
ストーリー内で使用している usePlayParameters() は meta.story() で定義した parameters._play_ を返します。StorybookとPlaywrightで使用できる共通の変数を定義したいときに使います。<Button> のラベルに crypto.randomUUID() を使用している理由は、「ストーリー上に偶然その文字列があることを防ぐため」です(「追加」や「削除」のようなラベルはコンポーネント自体がそのラベルを持っている可能性があり、他のボタンを押してしまう可能性があります)。
getPlayParameters()
ストーリーで定義した parameters._play_ が返ってきます。meta.story() で定義した parameters はストーリーのページの window.__STORYBOOK_PREVIEW__.currentRender.input.parameters に定義されます(Storybookの仕様です)。これを page.evaluate() で取得しています。これだけだと、getPlayParameters() の返り値が any になってしまうため、型引数でストーリーの型を渡し、返り値の型をストーリーで定義した parameters._play_ と同じになるようにしています(型をちゃんとしたいというよりは、IntelliSenseをちゃんと効かせたいからです。あんまり変わらないかもしれないですが、TSXファイルやストーリーをそのままimportするのは微妙な気がしたので、type-only importにしています)。これによりStorybookとPlaywrightで共通の変数を使用できます。
test()
test() の第1引数にはストーリー名を書きます。StorybookのストーリーのURLは title とストーリー名がわかればわかります(URLは /iframe.html?id=${title}--${storyName} の形式で、たとえば /iframe.html?id=components-button--play-on-click のようになります)。イタンジでは title をコンポーネントまでのパスとしており(e.g., "components/Button")、ストーリーまでのパス(src/components/Button/PlayOnClick.stories.tsx)とspecまでのパス(__tests__/playwright/components/Button.spec.ts)を似た形式で定義することで、test() の第1引数からURLを特定でき、Playwrightはストーリーにアクセスできます。
ストーリーにアクセスしてからストーリーが描画されるまでは若干時間がかかるため、test() では window.__STORYBOOK_PREVIEW__.currentRender.phase などを確認することで描画を待機しています。
waitForConsoleLog()
page.waitForEvent("console") のラッパーみたいなものです。第2引数で渡した関数の返り値が console.log() されるまで待機します。今回のケースでは、「<Button> をクリックしたときに console.log() が呼ばれているということは、onClick で渡した関数がちゃんと呼ばれている」、つまり、「<Button> の onClick() が動いている」と判断できます。
まとめ
メリット
上記によってPlay functionからPlaywrightに移行できました。コンポーネントのテストという観点では、「ブラウザ(ユーザー)になるべく近い挙動でテストできる」というメリットがあります。また、テストを書く側の視点では、Play functionよりもPlaywrightのAuto-waitingの方が圧倒的に書きやすいというメリットがありました。
デメリット
ストーリーとspecのファイルが遠い、今までPlay functionで書いていたことをファイルを分けて書かなければならないのが辛いです。ただ、辛いのはそれぐらいかもです。実行時間はAuto-waitingの分だけ伸びていそうですが、あまり変わらないのと、伸びていたとしてもテストの挙動が正しくなったことの結果であるため、許容できると思っています。
おわりに
ここまで完全移行したテンションでお送りしてきましたが、7割以上がまだPlay functionです。Play functionでflaky気味だったものを優先的に移行し、新しいコンポーネントのテストやテストを書き換えたり追加するときはPlaywrightで書いています。イタンジは5月から下期が始まるのですが、Frontendチームの下期の目標にPlaywright化を入れてあります。頑張りましょう!
採用情報
Frontendチーム、絶賛採用中です!カジュアル面談もお待ちしています!