こんにちは! ケップルでエンジニアをやっている芹田です。
今回はフロントエンドエンジニアの皆さんに向けた記事をお届けしたいと思います。
2023 年 6 月現在、ケップルでは UI コンポーネントに Mantine を採用して新規開発しているプロダクトがあり、Storybook 7 と Storybook test runner を使用してスナップショットテストを実施しています。ところが、特定のコンポーネントを使用すると、スナップショットに毎回差分が発生することに気付きました。
この問題を解決するために少々力技な対応が必要になったため、記事として公開します。
事象の詳細
Mantine の Select コンポーネントを使用したとき、input 要素の id が毎回異なる値になる。id を参照する aria-controls も毎回異なる値になる。結果として、本来は一致すべきスナップショットが一致しない。
調査
GitHub の issue の確認
同じ内容で困っている人は多そうで、issue が複数立っていますが、いずれもメンテナの Rtishchev 氏 から "Chrome と Safari のオートコンプリートの問題に対応するためにこうなっています" "自分で id を振るといいですよ“ と回答されています。 (Test Snapshot failing in Mantine v6, Regenerated className gash · Issue #3676 · mantinedev/mantine Snapshot testing with Jest · mantinedev/mantine · Discussion #467 )
しかし、複数箇所で使用されるコンポーネントに id を人間の手で振るのはいろいろな意味で厳しいので、別の対応が必要です。テストのときだけ id を振るという手もありますが、コンポーネントにテスト用のロジックが混入するので避けたいです。
上記 issue では Math.random の戻り値を定数にすることで解決しているようですが、それはそれで id が重複したり Math.random を使っている他の場所が壊れる可能性があるなどの問題が発生するので、他の方法を考えてみます。
Mantine のソースを読む
調査のために Mantine のソースを読むと、該当部分はすぐに見つかります:
export function randomId() {
return `mantine-${Math.random().toString(36).slice(2, 11)}`;
}
toString(36) で36進数表記 (0-9a-z) に変換してから小数部を 9 文字使っているようです。実際は軽く試した感じ 0.02 % くらいの確率で 9 文字未満になるようです。
整理
ここまでの情報を整理すると、要件は以下のようになります:
-
Mantine 以外で Math.random を使っているところを壊さない (Math.random の戻り値を定数にするプランは NG)
-
id を重複させない (Math.random の戻り値を定数にするプランは NG, 人間の手で id を振るプランも NG)
-
コンポーネントにテストのためのロジックを露出させない (テストのときだけ id を振るのは NG)
この要件から考えられる対応は以下です:
-
Mantine の randomId をモックする
-
Math.random をモックして再現性のある乱数を返すようにする
この2つの対応は五十歩百歩で、どちらも Mantine の内部実装に深く依存するためお話になりません。Mantineの内部実装が変更されて気づかないうちにテストが壊れる可能性があります。
実際、かつて randomId は Math.random ではなく nanoid が使われていました。ただ、Math.random に再現性がないのはそもそもテストとしてどうなんだろうとも思ったので、Math.random をモックすることにしました。
対応内容
再現性のある乱数を用意
乱数の再現性とは、乱数をすべて記録しておかなくても同じ乱数列を再現できることです。Math.random で生成する乱数は疑似乱数で、たいていの環境で XorShift128+ で生成されているようです。
このアルゴリズム自体は再現性があるのですが、シードを固定できないため、再現性がありません。ライブラリを探したところ seedrandom がちょうどよかったのでこれを使うことにしました。シードが固定でき、質の良い乱数が得られる(デフォルトだと ARC4 で暗号論的に安全な乱数が得られる)ので大変助かります。
テスト初期化時にモックする
Jest でスナップショットをとっているならば beforeEach で spyOn するだけですが、今回は Storybook test runner を使っているため、Playwright 上でモックする必要があります。いろいろ試しましたが、最終的に test-runner.js で export している config.preRender でモックすることにしました:
/** @type {import('@storybook/test-runner'.TestRunnerConfig)} */
const config = {
async preRender(page, _context) {
// page.addInitScript だとなぜかうまくいかない
await page.addScriptTag({
path: './node_modules/seedrandom/seedrandom.min.js',
});
await page.addScriptTag({ path: './.storybook/preload.js' });
},
}
preload.js の中身:
// seedrandom.min.js を読み込むと Math に seedrandom が生える
Math.random = new Math.seedrandom('storybook');
以上の対応で毎回固定の id が振られるようになり、テストが通るようになります! シードが 'storybook' の場合、最初に生成される id は必ず mantine-prg0dr56g です。
まとめ
今回、フロントエンドのテストにおいて Math.random が絡む場合に再現性がなくなる事象の対応を、Math.random をモックすることで対応しました。Web Crypto API が絡む場合などにも応用できると思います。参考になれば幸いです。