フロントエンドのテストをしよう
Webのフロントエンドの自動化を進めようか。という話をしていて、
「そもそもテストってなんだ?」
「フロントエンドに特有のテストってなんだ?」
「〇〇ってツール流行ってるらしいってどうよ?」
みたいなことを話をしていました。そうしたときに、やっぱり知識足らねぇなぁ。と思ったので、2,3日でゴリゴリと内容をまとめてみる作業をしてみました。
あんまりこういう書き方はしないんですが、私自身散発的な思考で、フロントエンドのテストを調べることをしたので、そのような語り口で書いてみようと思います。
以下の内容は、あくまで例なので、別にこういう仕事があったわけではないです。
とりあえず投げられた要求・仕様
とりあえずなんか仕事が振ってきた。パラパラと要求を聞いてみると、こんな感じだった。
- 承認のダイアログが欲しい
- メッセージのフォントはOswald
- メッセージは変更できる
- ボタンのテキストが変更できる
- ボタンをクリックすると閉じる
デザイン画はこんな感じ。
なるほどな。まぁ。なんか雑な仕様だけど、まぁいっか。作り始めようか。
とりあえずモジュールを作ってみる
import { Oswald } from '@next/font/google'
export const oswald = Oswald({ subsets: ['latin'] })
export function Dialog(props: { message: string; buttonLabel: string; onClick: () => void; }) {
return (
<div>
<p className={oswald.className}>{props.message}</p>
<button onClick={props.onClick} style={{ backgroundColor: "gray" }}>
{props.buttonLabel}
</button>
</div>
);
}
まぁ、メッセージ、ラベル、onClickで発火する関数が指定されているし、これでいいだろう。
フォントはOswaldが指定されているし、ボタンの色は画像通りグレーだし、まぁいいだろう。
とりあえず書いてみたテスト
なんかリーダーがうるさいしテストを書こう。まぁよく分からんが書いてみよう。
Reactのテストに関しては、ReactTestingLibraryというのをよく使うらしい。テストのフレームワーク自体は、よく使われているjestでいいだろう。
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Dialog } from '../Dialog';
describe('Dialog', () => {
it('renders a message and a button', () => {
render(<Dialog message="message" buttonLabel="OK" onClick={() => { }} />)
expect(screen.getByText(/message/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /OK/ })).toBeInTheDocument()
})
})
なんとなく行レベルのカバレッジは100%になった。これで十分だろうか?
さて、これは何をテストしているのだろうか?
確かに「メッセージは変更できる」という仕様は、messageのテキストを表示されているexpectがあるから大丈夫そうだ。
「ボタンのテキストが変更できる」という仕様は、OKのボタンがあることを確認するexpectがあるから大丈夫そうだ。
よくよく考えてみると、「ボタンをクリックすると閉じる」というテストが出来ていない。ここでは、onClickに関数を渡すことで閉じる実装をする予定だし、ボタンをクリックしたときに関数を呼び出していることを確認するテストを書こう。
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Dialog } from '../Dialog';
describe('Dialog', () => {
it('renders a message and a button', () => {
render(<Dialog message="message1" buttonLabel="OK1" onClick={() => { }} />)
expect(screen.getByText(/message1/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /OK1/ })).toBeInTheDocument()
})
it('call onClick when button is clicked', () => {
const fn = jest.fn(() => { })
render(<Dialog message="message2" buttonLabel="OK2" onClick={fn} />)
expect(fn.mock.calls).toHaveLength(0)
screen.getByRole('button', { name: /OK2/ }).click()
expect(fn.mock.calls).toHaveLength(1)
})
})
jest.fnの機能を用いて、fnが呼び出された回数をカウントすることで、テストが書けそうだ。
ボタンのクリックは、screen.getByRole('button', { name: /OK2/ }).click()
みたいな書き方が出来るらしい。
これで、なんとなく出来たような気がする。
非機能のテスト
これでテストは本当に良いのか?さっきまで書いていたテストは
- メッセージは変更できる
- ボタンのテキストが変更できる
- ボタンをクリックすると閉じる
という機能的なものしかなかった。しかし、それ以外にも
- メッセージのフォントはOswald
- ボタンの背景はグレー
といった外見的な仕様も存在している。この辺りをどのようにテストすべきだろうか?
そして、今回、テストしたいのがReactHookである。単体のコンポーネントなので、このような場合は、Storybookを使うようだ。とりあえず書いてみよう。
import type { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { Dialog } from '../pages/Dialog';
import { waitFor } from '@storybook/testing-library';
const meta = {
title: 'Dialog',
component: Dialog,
argTypes: {
onClick: { action: 'clicked' },
}
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
args: {
message: 'sampleMessage',
buttonLabel: 'sampleLabel'
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("sampleMessage")).toBeInTheDocument();
expect(canvas.getByRole('button', { name: /sampleLabel/ })).toBeInTheDocument();
}
}
export const Click: Story = {
args: {
message: 'message',
buttonLabel: 'OK',
},
play: async ({ args, canvasElement }) => {
const button = within(canvasElement).getByRole('button', { name: /OK/ });
await waitFor(() => expect(args.onClick).not.toHaveBeenCalled())
button.click()
await waitFor(() => expect(args.onClick).toHaveBeenCalled())
}
}
うながされるままにテストを書いてみたが、実質的にはDialog.test.tsxとほぼ同じことしかしていない。そうではないんだ。外見のテストをしたいのだ。
そういった外見のテストは、Import stories in testsというページがある。ここに、Playwrightをつかったテストがある。これを真似て書いてみよう。
import { Page } from "@playwright/test";
const { test, expect } = require('@playwright/test');
import axios from 'axios'
const hostName = 'localhost:6006'
const captureByStoryId = async (page : Page,id: string) => {
await page.goto(`http://${hostName}/iframe.html?id=${id}&viewMode=story`)
await page.waitForSelector('#storybook-root')
await page.waitForLoadState('domcontentloaded')
await page.waitForTimeout(2000)
expect(await page.screenshot()).toMatchSnapshot();
}
test("Capture", async ({ page } : {page: Page}) => {
const response = await axios.get(`http://${hostName}/index.json`)
for (const storyId in response.data["entries"]) {
await test.step(`storyId:${storyId}`, async() => {
await captureByStoryId(page,storyId)
})
}
})
Storybookではhttp://localhost:6006/iframe.html?id=${id}
という形で、それぞれのStorybookのstoryにアクセス出来るらしい。では、storyの一覧はどのように取得するか?というと、http://locahost:6006/index.json
に、現在のstoryの一覧が取得できるAPIが生えている。これをパースすることでStoryの一覧が取れそうだ。
実行して見ると、最初の1回目はスクリーンショットの取得で必ず失敗する。2回目以降は、ディレクトリに保存されたスクリーンショットの画像との差分を見ることで、チェックをすることが出来る。1回目は必ず目視で画像を見て、思った通りに画像が出力されているかを確認する必要がありそうだ。
Playwrightはよくできていて、playwright.config.tsに指定すると、複数のデバイスの解像度を指定してテストを動かせるらしい。リアクティブなコンポーネントも確認できて便利だ。
import { defineConfig, devices } from '@playwright/test';
const checkDevices = [
"Desktop Chrome",
"Desktop Chrome HiDPI",
"Pixel 4",
"Pixel 4a (5G)",
"Pixel 5",
"iPhone 12",
"iPhone 13",
"iPhone 13 Mini",
"iPad (gen 7)",
"iPad Mini",
"iPad Pro 11"
]
export default defineConfig({
testMatch: '*/*.e2e.test.tsx',
projects: checkDevices.map((device: string) => {
return {
name: device,
use: devices[device]
}
})
});
これでテストが一通り出来た気がする(フラグ)
仕様とは何か
今回の内容を整理すると以下のような分類になる。
- 機能的側面
- messageを指定できる機能
- buttonLabelを指定できる機能
- ボタンをクリックすると関数を呼び出す機能
- 外見的側面
- メッセージのフォントがOswald
- ボタンの色がグレー
ここで、「機能的側面」「外見的側面」はこの記事で独自の用語である。
「機能的側面」と言っているのは、「入力と出力の関係が明示的なもの」である。例えば、「messageを指定出来る機能」は「関数にmessageを指定したときに、DOM上でmessageという文字列が存在する」という内容である。では、一方で「メッセージのフォントがOswald」である。というのは「外見的側面」としている。「メッセージのフォントがOswaldである」というのは、画像でレンダリングする以外の方法での検証が難しい。そのため、「入力と出力の関係が非明示的なもの」と言える。別の視点からすると、「人間の目による感応的な試験を要するもの」と言えるかもしれない。しかし、これは捉え方次第である。例えば、「CSSのフォントの指定で、Oswaldと指定されている」でも、テストが出来ていると言えるかもしれない。この場合、「メッセージのフォントがOswaldである」というテストは、「機能的側面」のテストになりうる。しかし、OswaldはGoogleFontに依存した機能である。そのため、いつかはサービスが終了する可能性も考えられる。そうしたとき、フォントへのリンクが切れて、ブラウザによりフォールバックされ、別のフォントでレンダリングされる可能性はある。しかし、先ほどの「CSSのフォントの指定で、Oswaldと指定されている」というテストでは、この不具合は検知できない。このように「何をテストしたいか」「何をもって合格とするか」という基準次第でテストのアプローチは変わる。この部分に関しては、のちに議論したい。
本記事のテストの範囲と技術的アプローチ
今回の、この記事での技術スタックを一覧にすると以下の様になる。
最初の章で行ったテストはReactHookに対する機能的側面のテストだった。Jest+ReactTestingToolでは、jsdomという内部的にDOMをシミュレーションする機構でテストを行っていた。次の章に行ったのが、Storybookによる機能的側面のテストである。本質的なテストの内容としては、StorybookもJest+ReactTestingToolも変わらないため、重複して機能的側面のテストを行う意味はない。個人的には、この程度であれば、ReactTestingToolの方の選定が良いと考えている。理由は、速さである。Storybookは内部的にpupeeteer+Chromeの構成のようなので、どうしてもテストの実行へ時間がかかりがちである。そのため、この程度の簡単なテストであれば、ReactTestingToolの方が良いと思われる。しかし、jsdomによるシミュレーションは、ServiceWorkerやWebGLなどのブラウザ機能の再現が出来ないことがあるため、そういった機能の場合は、Storybookの方がやや有利だと思われる。"やや有利"といっているのは、ServiceWorkerやWebGLのテストはノウハウがあまりネットに無い点も鑑みて難しいと思われるので、テストする対象がそもそも修羅の道である危険性がある。最後は、Playwright+Storybookの、外見的側面のテストである。実際に、ブラウザを動作させて、レンダリングし、画像のスクリーンショットを保存しておくことで、今後、DOM構造やCSSをリファクタリングしたときにデザイン崩れが発生しないかが検知できるようになる。また、Playwrightの機能で、各端末のviewportが設定できるため、レスポンシブなデザインのテストにも対応できそうである。
学習コスト・実行コスト・テスト範囲のトレードオフ
前節で、「「何をテストしたいか」「何をもって合格とするか」という基準次第でテストのアプローチは変わる。」と書いたのは、テストの難易度や運用コストのトレードオフがあることである。今回、使った技術スタックは、「Jest」「React Testing Tool」「Storybook」「Playwright」の4つである。これは、初心者が始めるには学習コストのかかるスタックである。したがって実運用まで時間がかかりすぎるため、本当にやるべきか?という疑問がある。また、先に議論したように、機能的側面のテストをStorybookにするか。ReactTestingToolにするか。にも、トレードオフがあり、Storybookは時間がかかるが、作ってしまうと、Playwrightとの相性が良く、外見的側面のテストへの道筋が立ちやすい。しかし、そもそもの学習コスト・構築コストがかかる中で、実行速度も遅い。というネガティブな面がある。一方で、ReactTestingToolは、実際のブラウザを動かさないため比較的に速い動作速度で動く。一方で、外見的側面のテストは出来なくなる。そのため、スナップショットテストと呼ばれる、HTMLの出力を比較するテストで代用することになる。しかし、これはDOM構造の変化に弱く、本質的にブラウザにレンダリングされていないため、レスポンシブなWebページのデザイン崩れなどは検知できない。という欠点がある。その代わり、技術スタックが、「Jest」「React Testing Tool」で済むため、学習コストが安くすみ、実運用までが速いメリットがある。
うまくいかなかったこと・足りないテスト
本当は何をしたかったか。というとPlaywrightのコンポーネントテストである。
今回、Dialogを作る。という題材において、どうしてもReactHookのテストがしたかった。そうした場合、Playwriteのコンポーネントテストは適切な選定であるように思うが、少なくとも私の環境では動かなかった。そのため、コンポーネント単位のテストにおいて、Storybookを利用せざるを得なかった。というのが、この検証の裏側である。もしPlaywrightでうまくいっていた場合、Storybookが不必要となるので、技術スタックがシンプルになった。
少しPlaywrightに固執している面があるが、それはなぜか。というと、"ページ全体の外見的側面のテスト"である。今回は、"コンポーネント単位"であるが、その一方で"ページ全体の画面崩れの検知"を行いたかった。コード自体は、ほぼ上記のPlaywrightと同じでテストできるため、学習コストも安く済むはずであったが、うまくいかなかった。こういったE2E系のコンポーネントテストは他の技術スタックの選択肢もあり、よく見る技術スタックはstoryshotやstorycap+reg_suitなど、Storybookのアドオンと連携したタイプや、CypressなどのE2E系にもコンポーネントテストがあるようだ。
テストの範囲と技術的ソリューションの調査結果
ここでは、Webページ全体をPage、それぞれの中に存在する分離可能な部品、ReactHooksなどをComponent、それらから呼ばれる、純粋な関数やクラスなどの機能提供を行う部品をFunctionとしている。
繰り返しになるが、機能的側面としているのは、「入力と出力の関係が明示的なもの」。「外見的側面」としているのは「入力と出力の関係が非明示的なもの」「人間の目による感応的な試験を要するもの」である。
機能的側面のテストは、今までの蓄積があるため、ソリューションも豊富である。Functionのテストであれば、よく使われるJestや新興のVitestなどがある。Componentのテストでは、PlaywrightやCypressなど有名なE2Eテストツールで利用可能である。しかし、Puppeteerは公式ではComponentのテストを搭載していないようだ。また、jsdomを利用した実際のブラウザを用いないReact Testing Toolでもテストが可能である。Page単位のテストツールは多くあり、ほぼComponentと同じツール群である。古典的にはSeleniumもこの部類だろうか。
外見的側面には、2種類のテストの技術アプローチがある。1つはVisualRegressionTestと呼ばれるテストである。主に、ブラウザを介してスクリーンショットを取り、その差分を画像処理することでデグレを発見する方法である。もう1つはSnapshotTestである。主にはjsdomの仮想のブラウザ上で操作したときのHTMLを保存しておき、その差分を文字列比較により検知することでデグレを発見する方法である(別分野ではGoldenTestと呼ばれている技法に近い)。Reactに関するSnapshotTestはReact Testing Toolが強いが、JestのドキュメントにもSnapshotTestに関する言及はある。VisualRegressionTestに関して、Pageに関しては過去からの蓄積があり、ツールのテストの機能自体にはそれほど差はないように感じる。どちらかというとPlaywrightの強みは、EdgeなどChrome以外に対応しているとこだろうか。一方で、Component単位のテストに関しては、まだ技術が枯れていない雰囲気を感じる。そもそもStorybookの構築コストが重い。という話を耳にしたり、自分が触った限りだとPlaywrightはExperimentalな機能もあって、動きに難がある。そう考えた場合、Cypressの採用を考えた方がいいかもしれない。個人的になぜPlaywrightを選んだかというと、State of JS 2022のテストの項目で高い評価が出ていたので、触っていた。
まだ出来ていないこと
- ボタンをクリックしたときの色のVisualRegressionTest
- ダイアログが0.5秒後にフェードアウトするようなVisualRegressionTest
- marqueeタグのVisualRegressionTest
1.に関して言うと、VisualRegressionTestは動的なものについて検証が難しいように思える。最近は、ボタンを押下したときの色も変えることができるが、そもそも一瞬しか映らない場合は、どのように検証すべきか?というところは、まだわかっていない。そのため、VisualRegressionTestは大規模にデザインが崩れる。ということを検知するような割と大味なテストになりうるだろう。という予感がある。2.に関しては、アニメーションである。1.に関しては、マウスの押下を長押し出来れば、なんとかなる。という予感があるが、「0.5秒後にフェードアウト」のような、"時間に関する制約がある仕様"、"フェードアウトというプログラムで記述しにくい仕様"に関しては、おそらくテストが出来ないだろう。という予感がある。3.に関しては、marqueeタグなんてレガシーなものを使うな。という議論はあるが、本質的に言いたい部分は、そこではない。「marqueeのようなレガシー規格で動作がはっきりしない。」であったり、「ブラウザへの実装依存がある機能」に関しては、辛い思いをしそうである。このmarqueeタグをやり玉に挙げたのは、「常に動き続ける要素」だからである。これも、おそらくはブラウザのロードタイミング等で、絶妙にタイミングがズレると思うので、VisualRegressionTestは辛いことになるだろう。これが厄介なのは、一般的なタイマーや乱数の場合は、モックを挟み込むことで、動作を無理やり固定させる技があるが、ブラウザに埋め込まれた実装に関しては、おそらく無理な印象がある。たぶん、誰も興味はないが、marqueeタグはDeprecatedだが、最新のChromeでも動くし、手元のPixel7でも動いた。(昔よく見たblinkタグは動かなくなってる)
https://developer.mozilla.org/ja/docs/Web/HTML/Element/marquee
感想
色々と必要に駆られてフロントエンドに関するテストを調べたが、フロントエンド特有の難しさを実感する内容であった。DOMだけでは、最終的な成果物が分からず、ブラウザでレンダリングされる必要がある。というのがひとえに難しい。そのうえで、javascriptが動いているため、とても介入しにくいなぁという印象が強かった。昔、Seleniumを触っていたが、その当時は、めちゃめちゃ不安定で使い物にならなかったが、Playwrightは自分の環境ではかなり安定しており、とてもよかった。ただ、他人にVisualRegressionTestのコードを渡したとき、母艦のOSがMacかWindowsかでレンダリング結果が異なるなど、まだまだ修羅の道がありそうだ。そのうえで、CI上で動かすときも多分意図しない差分が発生するだろうと予想している。もしかしたら、CI上には日本語フォントが入っておらず文字が豆腐になって、ヒドイ差分を見る羽目になりそうだ(ちゃんとしたサイトなら全部のフォントをしているだろうが)。このように、まだまだこの分野も、やることが多そうだなぁ。という実感のある内容でした。