🎬

アニメーションのフレームをテストしない。その理由を解説します。

に公開

「アニメーションのテストは、開始と終了の状態だけをチェックすればいい」

最初は、滑らかに動く 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
  • 方法:
    1. 初期状態のアサート: アニメーションが始まる前の状態を確認します。(例:サイドバーが非表示である)
    2. アクションの実行: ユーザーのアクション(クリックなど)をトリガーします。
    3. 最終状態のアサート: アニメーションの完了を待ち、最終的な状態を確認します。(例:サイドバーが表示され、フォーカスが当たっている)
// 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
  • 方法:
    1. コンポーネントの「終了状態」の storybook コンポーネントを作成します。
    2. 初回実行時にそのスクリーンショットを「スナップショット」として保存します。
    3. 次回以降のテストで、現在の表示とスナップショットを比較し、差分があれば検知します。

これにより、意図しない見た目の変更を確実に捉えることができます。

3. 滑らかさのテスト(パフォーマンスのテスト)

目的:アニメーションはユーザーにとって快適な体験だったか?

「フレーム単位でテストをしない」と言いましたが、その「過程」を全く無視するわけではありません。フレームごとの位置をチェックするのではなく、「フレーム落ち(jank)」がなかったかをチェックします。

  • ツール: Playwright Tracing, Lighthouse, etc
  • 方法:
    • Playwright Tracing: テスト実行時にトレースを有効にすると、パフォーマンスのタイムラインを詳細に確認できます。フレームレートの低下や、レンダリングに時間がかかっている箇所を視覚的に特定できます。
    • Lighthouse: パフォーマンススコアの一部として、アニメーションのパフォーマンスも評価されます。CI に組み込むことで、パフォーマンスの低下を継続的に監視することも考えられます。

このアプローチは、ユーザー体験の質、つまり「アニメーションが滑らかだったか」を直接的に計測する方法であり、現代のフロントエンドテストにおける重要な視点です。

まとめ:現実的で、意味のあるものをテストしよう

アニメーションのフレームごとのテストは、脆く、メンテナンス性が低く、間違った側面に焦点を当てています。

私たちが本当に注力すべきなのは、以下の 3 つではないでしょうか。

  1. 状態の正当性: アニメーションは期待される結果をもたらしたか?
  2. 見た目の完全性: その結果は意図通りに表示されているか?
  3. 体験の品質: その過程はユーザーにとって快適だったか?

このアプローチをとることで、より丈夫で、メンテナンスしやすくて、そして何よりもユーザーの体験価値を保証するテストを書くことができると思います。

いかがでしたか?皆さんの現場でのフロントエンドテスト戦略についても、ぜひコメントで聞かせてください!

脚注
  1. https://developer.mozilla.org/ja/docs/Web/API/setTimeout ↩︎

  2. https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function/cubic-bezier ↩︎

サイボウズ フロントエンド

Discussion