アニメーションのフレームをテストしない。その理由を解説します。
「アニメーションのテストは、開始と終了の状態だけをチェックすればいい」
最初は、滑らかに動く UI を見て、「テストの開始と終了しか見ていないなんて、全体像を見逃していないか?」なんて疑問に思ってました。
今となっては「まあ、そうだろうな」と何となく理解しつつも、ちゃんと全体的な理由をまとめたことはありませんでした。
こんにちは!サイボウズ株式会社フロントエンドエンジニアの protein_mochi です。
この記事では、なぜアニメーションの「フレーム単位」をテストすることが非現実的で、代わりに取れる方法を簡潔に解説します。
「ナイーブ」な試み:なぜフレーム単位のテストはだめなのか
まず、なぜ UI のアニメーションを 1 フレームずつテストするアプローチがうまくいかないのかを見ていきましょう。
例えば、「サイドバー要素が 50 ミリ秒ごとに正しい位置にあるかを検証する」ようなテストを想像してみてください。
// これはアンチパターンの疑似コードです
test("サイドバーがスムーズに開くことを確認する", async () => {
const sidebar = await screen.getByTestId("sidebar");
// 初期状態:画面外にあることを確認
expect(sidebar).toHaveStyle("transform: translateX(-100%)");
// アニメーションを開始
fireEvent.click(screen.getByRole("button", { name: /サイドバーを開く/ }));
// 50ms後:少しだけ表示されているはず...?
await new Promise((r) => setTimeout(r, 50));
expect(sidebar).toHaveStyle("transform: translateX(-80%)"); // ← このテストは非常に不安定
// 100ms後:もっと表示されているはず...?
await new Promise((r) => setTimeout(r, 100));
expect(sidebar).toHaveStyle("transform: translateX(-60%)"); // ← 同様に不安定
// ...
});
このテストはなぜ「ナイーブ」なのでしょうか。
1. タイミングの不確実性
setTimeout
は、指定した時間や次のフレームでコールバックが必ず実行されることを保証しません。これらはタスクをキューに追加するだけで、ブラウザーのメインスレッドが混雑状態であれば、実行は遅延します。
MDN のsetTimeout
のドキュメントにも、この遅延の可能性について言及されています。
setTimeout()
に指定した遅延時間は、その時間が経過した後にコールバック関数が実行されることを保証するものではありません[1]。
この不確実性により、テストは実行環境の負荷によって成功したり失敗したりする「フレーキー(flaky)」なものになります。
2. 環境の非一貫性
現在のローカルマシンと CI/CD 環境のスペックは同じでしょうか?ブラウザーの仕様や性能は一貫してるでしょうか?答えはほとんどの場合「No」です。CPU の性能、メモリ量、さらには実行中の他のプロセスによって、アニメーションの実行速度は変わります。
- ローカル vs CI: 高性能な開発マシンではスムーズに動くアニメーションも、リソースが限られた CI コンテナ上では遅くなるかもしれません。
- ブラウザ差: Chrome、Firefox、Safari では、レンダリングエンジンが異なるため、アニメーションのパフォーマンスに微妙な差が出ることがあります。
これにより、「ローカルでは成功するのに、CI では失敗する」、「chrome では問題ないのに safari ではうまくいかない」という、開発者が最も嫌う状況が生じます。
3. 保守性の罠
さて、デザイナーの方が「あのアニメーション、もうちょっと弾む感じにしてくれないかな?」と言ってきたらどうなるでしょう。恐らく CSS のcubic-bezier
関数を活用して、アニメーションが美しく改善されるかと思います[2]。アニメーションフレームテストを除いては。
既存のテストコードは過度に具体的な値に縛られています。つまりは、保守性の地獄が生じてしまうのです。テストを「修正」するには、すべてのアサーションを再計算し、更新しなければなりません。
さらには、cubic-bezier
関数のような複雑な計算法を活用する場合は、CSS の移動経路をフレーム単位で未来予知するために、以下のようにテストコード内に関数を再実装する必要が生じます。想像するだけでゾッとしますね。
// style-predictor.js
import { myPerfectCubicBezier } from './browser-easing-reimplementation.js';
function getCorrectStyleForMillisecond(t) {
// ...ここに完璧なcubic-bezier関数のロジックを...
const exactValue = /*...多くの美しい数式... */;
return { transform: `translateX(${exactValue.toFixed(4)}px)` };
}
私たちはブラウザが CSS を正しく実行することを信頼すべきです。開発者の仕事は、アプリケーションのロジックをテストすることであり、ブラウザのアニメーションエンジンをテストで再実装することではありません。
4. 膨大なデータとリソースの問題
ここまでの問題をすべて魔法のように解決できたとしましょう。タイミングのずれもなく、どんな環境でも一貫性があり、CSS の変更にも強いテストが書けたとします。しかし、それでもなお、物理的な壁が存在します。それは、生成されるデータの量です。
この問題は、テストの種類(単体、結合、E2E)を問いません。
例えば、一般的な 60fps(フレーム/秒)のアニメーションを考えます。たった 1 秒のアニメーションをテストするだけでも、理論上は 60 フレーム分の状態(スタイル情報やスクリーンショット)をキャプチャし、比較する必要があります。
- 単体・結合テスト: 1 つのコンポーネントテストで 60 個のアサーションが生まれます。
- E2E テスト: これがユーザーの一連の操作をテストする E2E テストになればどうでしょう?数秒間のアニメーションが複数回発生するシナリオでは、数百、数千のフレームを扱うことになります。
必要とされるリソースと生成されるデータは膨大になり、テストの実行時間を著しく増加させ、テスト実行サーバーのストレージを圧迫します。これは現実的ではありません。テストは可能な限り早くフィードバックを返すためのものであり、ボトルネックになるべきではないのです。
これらの理由から、フレームごとのテストはメンテナンス不可能な負債となり、現実的な選択肢ではないのです。
現実的な解決策:本当のすべきテスト
では、どうすれば良いのでしょうか? テストの目的を「過程」から「結果」と「体験」にシフトすることです。いくつかの例えを紹介します。
1. 正当性のテスト(状態のテスト)
目的:アニメーションがその目的を達成したか?
これは最も重要で、基本的なテストです。UI の状態が期待通りに変化したかを確認します。
- ツール: Playwright, Cypress, etc
-
方法:
- 初期状態のアサート: アニメーションが始まる前の状態を確認します。(例:サイドバーが非表示である)
- アクションの実行: ユーザーのアクション(クリックなど)をトリガーします。
- 最終状態のアサート: アニメーションの完了を待ち、最終的な状態を確認します。(例:サイドバーが表示され、フォーカスが当たっている)
// Playwrightを使ったテストコードの例
import { test, expect } from "@playwright/test";
test("サイドバーが正しく開閉すること", async ({ page }) => {
await page.goto("/my-app");
// 1. 初期状態:サイドバーが表示されていない
await expect(page.getByTestId("sidebar")).toBeHidden();
// 2. アクション:開くボタンをクリック
await page.getByRole("button", { name: /サイドバーを開く/ }).click();
// 3. 最終状態:サイドバーが表示されている
// Playwrightの`expect`は自動で待機してくれるため、`setTimeout`は不要
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("sidebar")).toBeFocused();
});
このテストは、アニメーションを未来予知(何ミリ秒に、どの CSS プロパティが、どこに位置するか)しようとせず、ユーザーにとっての「結果」を保証します。
2. 見た目のテスト(Visual Regression Testing)
目的:アニメーションの最終状態が意図通りに表示されているか?
状態のテストはロジックを保証しますが、見た目の崩れ(CSS の崩れなど)は検知できません。そこで VRT が役立ちます。
- ツール: Storybook + Chromatic, Loki, reg-suit, etc
-
方法:
- コンポーネントの「終了状態」の storybook コンポーネントを作成します。
- 初回実行時にそのスクリーンショットを「スナップショット」として保存します。
- 次回以降のテストで、現在の表示とスナップショットを比較し、差分があれば検知します。
これにより、意図しない見た目の変更を確実に捉えることができます。
3. 滑らかさのテスト(パフォーマンスのテスト)
目的:アニメーションはユーザーにとって快適な体験だったか?
「フレーム単位でテストをしない」と言いましたが、その「過程」を全く無視するわけではありません。フレームごとの位置をチェックするのではなく、「フレーム落ち(jank)」がなかったかをチェックします。
- ツール: Playwright Tracing, Lighthouse, etc
-
方法:
- Playwright Tracing: テスト実行時にトレースを有効にすると、パフォーマンスのタイムラインを詳細に確認できます。フレームレートの低下や、レンダリングに時間がかかっている箇所を視覚的に特定できます。
- Lighthouse: パフォーマンススコアの一部として、アニメーションのパフォーマンスも評価されます。CI に組み込むことで、パフォーマンスの低下を継続的に監視することも考えられます。
このアプローチは、ユーザー体験の質、つまり「アニメーションが滑らかだったか」を直接的に計測する方法であり、現代のフロントエンドテストにおける重要な視点です。
まとめ:現実的で、意味のあるものをテストしよう
アニメーションのフレームごとのテストは、脆く、メンテナンス性が低く、間違った側面に焦点を当てています。
私たちが本当に注力すべきなのは、以下の 3 つではないでしょうか。
- 状態の正当性: アニメーションは期待される結果をもたらしたか?
- 見た目の完全性: その結果は意図通りに表示されているか?
- 体験の品質: その過程はユーザーにとって快適だったか?
このアプローチをとることで、より丈夫で、メンテナンスしやすくて、そして何よりもユーザーの体験価値を保証するテストを書くことができると思います。
いかがでしたか?皆さんの現場でのフロントエンドテスト戦略についても、ぜひコメントで聞かせてください!
Discussion