はじめに
こんにちは!
私は今年の 3 月に株式会社 Psychic VR Lab にフロントエンドエンジニアとして join し、主に STYLY Gallery の保守・開発を行っていました。
STYLY Gallery とは、STYLY で作成された XR 作品を探したり、Web 上で体験することが出来るギャラリーサイトです。
今回はそんな STYLY Gallery にフロントエンドのテストを導入するまでについて書こうと思います。
2023 年 3 月, 時点の状況
STYLY Gallery は Next.js v12 で書かれていました。
この時点でフロントエンドのコードにテストと呼べるものはなく、静的コード解析が導入されている程度でした。
しかし、デザインは 6 種類の画面サイズが用意されており、何かリリースする度に手動で動作やデザイン崩れなどの確認を画面サイズ分する必要がありました。
ものによってはデザイナーによるデザインチェックや QA エンジニアによるチェックが入ることもありました。
これではコストが高すぎてライブラリのアップデートだったり、ちょっとしたリファクタリングだったりも手軽に行えません。
そういった背景もありテストを導入することになりました。
2023 年 12 月, 現在のテスト構成
今現在のテスト構成は以下のようになっています。
-
Jest + React Testing Library
関数や Component の機能テスト
-
Playwright
Page 単位の VRT
-
Storybook + Playwright
Component 単位の VRT
それぞれのツールを導入するまで
STYLY のフロントエンドエンジニアは私と業務委託の方を合わせて 2 人です。
中でも私はフロントエンドのテストを書いた経験がほとんどありませんでした。
以前 Cypress を用いた E2E テストを少し書きましたが、結局テストを書くコストが高すぎる(新機能のリリースが遅れる)という理由で無くなりました・・・。
フロントエンドのテストは昨今ようやく広まりつつある印象ですが、導入しようと思っても社内に知見を持っている人がおらず書き方がわからない、テストの有用性が分からない(過去の苦い思い出を思い出しつつ・・・)などあると思います。あくまで一例として誰かの参考になればと思います!
1. Jest + React Testing Library(機能テスト), Storybook
まず手始めに 書きやすい機能テスト から始めましょうということになりました。
またそのついでにデザインチェックの工数を下げるために Storybook の導入も進めました。
Storybook について、これまでは PR でコードレビューをして OK が出たらマージ、Staging 環境等でデザイナーさんにデザインチェックをお願いするという流れでしたが、デザインレビューによる出戻りがあると PR の段階でデザインレビューもした方が効率いいよねという話が上がり導入することになりました。他にも Storybook に書きやすい用に Component を設計する必要があり、設計を見直す機会にもなります。後に出てくる Component 単位の VRT でも活躍してくれます。
Jest + React Testing Library 導入の流れは以下のような感じでした(Component に焦点を当てています)。
a. テストについての勉強会を開催
幸いテストについて知見の豊富な方が社内にいますので、Web エンジニア向けにテストについての座学とモブプロのような感じで Component のテストを書く勉強会を開催していただきました(この点は環境が恵まれていると言えますね)。
また本をいくつかおすすめしていただいたのでそれらを読んだりしていました。
個人的にはテスト駆動開発を見様見真似で実践してみたことがどんな粒度で機能を切り出すべきかを考えるいい機会になり、テストを書いていなかったときには持ってなかった視点を得られてかなり良かったです。
b. テストが書けそうな Component をリストアップ
なるべくシンプルな Component からテストを書き始める為以下の条件を満たす Component をテストが書けそうなものとしてリストアップしていきました。
- 機能が多すぎないこと
- なるべく末端であること
- なるべく window, document などの global object を使用していないこと
- なるべく外部ライブラリに依存していないこと
c. Coverage 30% という目標を決め、ひたすらテストを書く
お手本用に 1 つテストを書いてもらいました、後はひたすらテストをゴリゴリ書いていきます。
ちなみに、Component のテストはテストをシンプルにするために機能は hooks に切り出しそれをテストする戦略を取りました。Component の方では適切に関数が呼ばれているか、描画かかわるものであれば適切に変化しているかなどをテストしています。
何とか Coverage 30% を達成した時点でテストが書けそうな Component が無くなりました。
残った Component はそのままだとテストが書きにくいのでリファクタリングが必要であったり Page Component だったりです。
それならリファクタリングすればいいのでは?という簡単な話ではなく、Component を切り出したりすることで Style が崩れる可能性を考えるととても気軽にはできません(テストを書くためにテストが必要・・・?頭がおかしくなりそうだ)。
ということで次はロジックを見るのではなく見た目の方からアプローチすることにしました。
2. Playwright(VRT)
機能テストは書けるようになりました。しかし Style など非機能テストについてはまったくカバーされていません。
冒頭にも書きましたが、STYLY Gallery には画面サイズが 6 種類用意されています。他にも英語ページと日本語ページが存在します。最大でページ数 * 画面サイズ * 言語数 分確認する必要があります。
これは大変すぎるのでページ毎に VRT (Visual Regression Test) を導入することにしました。
VRT を実現するためには様々なツールがありますが、STYLY Gallery ではいろんなブラウザで動作させることができる Playwright を選択することにしました。
playwright は projects に配列で環境を指定してあげることで複数環境でテストを実行してくれます。
// devices の中に Playwright が定義しているブラウザの情報が入っています
import { defineConfig, devices } from "@playwright/test"
// 必要な言語と画面サイズを配列で持ちます
const languages = ["ja", "en"]
const viewSizes = [
{ width: 2560, height: 1327 },
{ width: 1921, height: 1000 },
{ width: 1920, height: 1043 },
{ width: 1601, height: 829 },
{ width: 1600, height: 829 },
{ width: 1281, height: 730 },
{ width: 1280, height: 730 },
{ width: 961, height: 730 },
{ width: 960, height: 730 },
{ width: 641, height: 730 },
{ width: 640, height: 730 },
]
const firefox = "Desktop Firefox"
const safari = "Desktop Safari"
const chrome = "Desktop Chrome"
export default defineConfig({
...
// 言語分それぞれのブラウザで実行されるように配列を作成します
projects: languages.flatMap((lang) => {
return [
{
name: `firefox-${lang}`,
use: { ...devices[firefox], locale: lang },
},
{
name: `webkit-${lang}`,
use: { ...devices[safari], locale: lang },
},
].concat(
// 全画面サイズを全てのブラウザで実行するのは流石に多すぎて実行時間が気になってくるので chrome のみ言語に加え全画面サイズで実行するようにしています。
viewSizes.flatMap((viewSize) => {
return [
{
name: `chromium-${viewSize.width}-${viewSize.height}-${lang}`,
use: {
...devices[chrome],
// devices の object を上書きすることで別の言語・別の画面サイズを実現しています
viewport: { width: viewSize.width, height: viewSize.height },
locale: lang,
},
},
]
})
)
}),
})
上記の設定により chrome は 言語 2 種類 * 画面サイズ 11 種類の 12 回、safari と firefox は言語 2 種類ずつで計 16 回ページ毎に VRT を行っています。
ちなみにデザイン上用意されているサイズは 6 種類ですが、経験上ブレイクポイント付近で Style が崩れやすいため自動テストではサイズを増やしています。手軽に環境を増やしたり減らしたり変更出来たりするのは自動テストの良い所といえますね。
さて、これでページ毎に VRT を書くことができるようになりました。
ページの VRT もゴリゴリ書いていきましょう。
が、更に問題が出てきます、Component の状態による見た目はどうするのかです。それもこの VRT の責務なのでしょうか。
そのページでしか使用されていない Component であればアリかもしれません、しかしヘッダーやフッターなど全ページで表示されるコンポーネントはどうでしょうか。
STYLY Gallery の場合ログインしているか否か、アイコンを押したときに表示されるメニューのスタイルはどうするのか、それをページ毎に Component の状態も追加でテストケースを書くべきでしょうか?特定のページでは Component の状態もテストするなど例外を持ち込むべきでしょうか?
答えは否だと思います。無暗にテストケースを増やせばテストの実行時間が増える(ただでさえ長いのに)ことになりますし、例外を入れると見通しが悪くなってしまいます。
そこで次につながります。
3. Storybook + Playwright(VRT)
せっかく Storybook を使っているわけですから、Storybook のレンダリング結果で VRT をすればいいのです。
Storybook の VRT は Storycap や reg-suit が挙げられますが、Playwright を使用することにしました。そもそも VRT として稼働させていたことのほかに、さまざまな種類のブラウザで動作させることができるのというのが一番のメリットだと思います。
以下の手順を踏むことで Component 毎の VRT を実現しています。
Storybook の再利用方法
Storybook を Playwright で再利用する方法については公式ドキュメントにヒントがあります。
const { test, expect } = require('@playwright/test');
test('Login Form inputs', async ({ page }) => {
await page.goto('http://localhost:6006/iframe.html?id=components-login-form--example');
const email = await page.inputValue('#email');
const password = await page.inputValue('#password');
await expect(email).toBe('email@provider.com');
await expect(password).toBe('a-random-password');
});
上記の例を見ると分かる通り、http://localhost:6006/iframe.html?id={story-id}
で Story にアクセスできそうです。
ちなみに、Storybook のページをデベロッパーツールで見るとわかるのですが、preview のエリアは iframe でレンダリングされているようですね。
右上のボタンからも http://localhost:6006/iframe.html?id={story-id}
形式の URL を取得できます。
Playwright 実行時に Storybook をサーブする
アクセス方法は分かりました、次は Playwright でテストを動かすときに Storybook をサーブする必要がありそうです。
ここれについても公式ドキュメントにヒントがあります。
ここには Storybook を静的 Web アプリケーションとしてビルドする方法がかかれています。これは使えそうです。
Storybook には静的ファイルを生成するコマンドが用意されているようですので、それをそのまま実行します。
Storybook を初期化する際に package.json に以下の npm stript が追加されているはずです(なかったら足しましょう)。
{
"scripts": {
"build-storybook": "storybook build",
...
},
...
}
このコマンドを実行することで storybook-static
というディレクトリが生成されビルド済みのファイルが生成されます。
$ npm run build-storybook
生成したファイルを Playwright 実行時にサーブできるように Playwright セットアップ時に Express 等でサーバーを立てるようにします。
playwright.config.ts
の globalSetup
にファイルを指定することが出来るので、そこで思う存分セットアップしましょう。
import express from "express"
async function globalSetup() {
// Storybook のセットアップ
const app: express.Express = express()
// storybook-static ディレクトリを指定します
app.use(express.static("storybook-static"))
// 任意の port で受けるように
app.listen(6006)
// その他に Next.js, API などのセットアップなどを行っています
...
}
export default globalSetup
これで Playwright 実行時に Storybook も一緒にサーブされるようになりました!
Story の一覧を取得する
Story のページへ訪れることが出来るようになりましたが、このままだと Story 毎にテストケースを手動で書くことになりそうです。
Component 毎に Story を書いて、VRT 用のテストも追加する・・・面倒くさすぎる。。
なんとかここを自動化できないかと Storybook を眺めていると、ブラウザのデベロッパーツールのネットワークタブで index.json
というファイルを request しているのを見つけました。
中身を見てみると Story の id を含む全 Story の情報が格納されているようです!これは使えそう!
幸い Storybook の build storybook-static/
内にも同じ index.json
があるようです。これを活用させてもらいましょう。
生成されたファイルを使って Story 毎に Playwright でページを開くようにテストを書きます。
import * as fs from "fs"
import path from "path"
import { test, expect, Page } from "@playwright/test"
import { waitForImgComplete } from "../utils"
// storybook-static/index.json の entries に全 Story のデータが入っています
const entries = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, "[root までの path]/storybook-static/index.json"),
"utf8"
)
)["entries"]
const captureByStoryId = async (page: Page, id: string, lang: string) => {
await page.goto(
// setup で立てた Storybook のサーバーにアクセスします。
// QueryParams の id に story の id を入れることで任意の story のページを開くことが可能です。
`http://localhost:6006/iframe.html?id=${id}&viewMode=story&lang=${lang}`
)
// しっかりレンダリングされる様に画像のレンダリングを待つ処理などを関数に定義して入れたりしています。
await waitForImgComplete(page)
expect(await page.screenshot()).toMatchSnapshot(`${id}.png`)
}
// for 文で全 story を回します。
for (const [storyId, body] of Object.entries(entries)) {
test(`Component snapshot storyId:${storyId}`, async ({ page }, testInfo) => {
await captureByStoryId(
page,
storyId,
testInfo.project.use.locale
)
})
}
これで Component 毎に VRT が出来るようになりました!
Story 単位で VRT が実行されるので Component の状態毎にテストすることも可能です。
Storybook の index.json は何者なのか
Storybook はデフォルトだとオンデマンドで Story の読み込みを行っているようです。
https://storybook.js.org/docs/configure#on-demand-story-loading
index.json はオンデマンドモードの支援のために生成されているようです。
https://storybook.js.org/docs/api/main-config-features#buildstoriesjson
このファイルを外部から使用する方法などのドキュメントは見当たらなかったため使用する場合はその点を理解して使う必要があります。
VRT については実行速度や Flaky Test に苦しめられる事になるのですが、それはまた別のお話・・・
おわりに
STYLY Gallery にフロントエンドのテストを導入したことについて書いてみました。
テストといっても、単体テスト、結合テスト、E2E テストなどなど、いろんな種類があります、それぞれのテストにメリットデメリットがあります。STYLY Gallery では単体テスト・結合テスト(機能テスト)と VRT を導入しました。E2E テストはサーバーサイドのテストがしっかりしていることや現状複雑な状態管理が必要なページがそれほど存在しない事からまだ導入していません。
フロントエンドのテストは慣れていないと大変だったり、コストだけかかって必要性がわからないといったことに陥りがちだと思います。今のプロダクトにとってどんなテストが必要なのかしっかり考えて導入を進めていく事が大事だと思います。無暗に追加するのではなく必要なテストを追加することでテストのありがたみも実感できると思いますし、テストを書くモチベーションにもなって良いと思います。
まだまだ模索中ではありますが、この記事があくまで一例として誰かの参考になれば幸いです!
宣伝
PsychicVRLab では Unity エンジニア・サーバーサイドエンジニアを募集しています!!ご応募お待ちしています!!