Canvas に流れるコメントを E2E でどう検証するか

はじめに

株式会社ドワンゴの教育事業本部で、学習サービス「ZEN Study」の Web フロントエンド開発を担当している松下誠太です。この記事では、ZEN Study の受講生の画面に流れるコメントを検証する E2E テストの取り組みと、その実装時にぶつかった問題をどう解決したかをご紹介します。

なお、Playwright などのテストフレームワークで E2E テストを書いた経験がある方を想定して書いています。テスト自体が初めてという方には前提の説明が不足する部分があるかもしれません。

背景:リアルタイム配信基盤の移行

ZEN Study の授業画面に流れるコメントなど、リアルタイム通信に使用している配信基盤は現在 WebSocket ベースですが、今後 HTTP + Protobuf ベースの新しい配信基盤へ移行することになりました。

この移行で重要なのは、このリアルタイム配信基盤を使用する授業のあらゆる機能が、ユーザーから見て「何も変わらない」ことです。特に、エッジケースで静かに壊れる不具合は機械的なチェックがないと見逃されがちです。そのため、今まで毎回手動で行っていた動作確認を自動テストに置き換えることにしました。

このリアルタイム配信基盤の上には、コメントやアンケート、挙手など複数の機能が実装されており、テスト対象となる機能は多岐にわたりますが、その中でも一筋縄ではいかなかったのが「動画の上に流れるコメント」の検証自動化でした。

canvas の中を流れるコメントは直接読めない

授業画面のスクリーンショット(動画未配信)

授業画面で流れるコメントは動画の上に canvas のコメントレイヤーとして描画されています。

通常の Playwright であれば、getByText("コメント") でその文字列を含む要素を取得したり、toHaveText("コメント") で特定の要素がその文字列を含むか検証したりすることで、「画面にこのテキストが表示されているか」を検証できます。しかし canvas に描かれた文字は DOM 要素ではないので、これらのセレクタには一切引っかかりません。

コメントのテストで守りたいのは「受講生の目に、コメントが見える形で届いているか」です。その一番大事なところが、ちょうどテストの死角に入っていました。

また、配信基盤の移行によってコメント受信処理の実装そのものが入れ替わるため、特定の実装に依存しない「最終的な描画結果そのもの」を end-to-end で検証したいと考えました。

そこで、「canvas のスクリーンショットを撮って、 OCR で読む」というアプローチを試すことにしました。

canvas のスクリーンショットを撮って OCR で読む

文字を認識するための選択肢(OCR のアプローチ)は、例えば LLM に画像を丸投げする方法なども検討しましたが、将来的に GitHub Actions のような CI 環境でも動かす可能性があることも考慮して、軽量な Tesseract.js を使用することにしました。Tesseract.js は、Google が開発したオープンソースの OCR エンジン Tesseract の JavaScript ポートで、外部サービスへの依存なしに Node.js 上で文字を認識できます。

  1. canvas のスクリーンショットを撮る
  2. Tesseract.js で文字認識
  3. 期待文字列と照合

読み取った文字列は、そのまま完全一致で比較するのではなく、正規化したうえで部分一致させています。OCR には揺れが出るので、比較する側でも歩み寄る設計にしました。実際のコードから要点だけ抜き出すと、次のようになります。

function 正規化(text: string) {
  // 全角/半角の揺れ(NFKC)、空白の混入、大文字小文字の差を吸収する
  return text.normalize("NFKC").replace(/\s+/g, "").toLowerCase();
}

async function Canvasにコメントが描画されていること(expectedText: string) {
  const ocrText = await Canvasからコメント文字列を読み取る();
  expect(正規化(ocrText)).toContain(正規化(expectedText));
}

そして、送信者の手元だけ確認しても、他の画面にまで届いているかはわかりません。そのため、異なる立場の画面をそれぞれ検証します。

  • コメントを送信した受講生本人
  • コメントを受信する他の受講生
  • 受講生のコメントを確認する先生(先生用の管理ツール)

大枠の設計は以上です。ただし、この仕組みを実際のテスト環境で安定稼働させるには、いくつかの泥臭い課題をクリアする必要がありました。

ハマりどころと工夫

背景の動画で OCR が誤読する

コメントレイヤーの canvas の裏には授業の動画が再生されています。そのまま撮ると背景の映像が文字に重なり、OCR が盛大に誤読します。対策は単純で、撮影中だけ videovisibility: hidden にすることにしました。撮り終わったら finally で必ず元に戻します。

日本語 OCR が弱い

前提として、ZEN Study の授業画面には「受講生が『なるほどボタン』を押すと、画面に『なるほど!』というコメントが自動で流れる」など、「日本語の定型文が自動で流れる」機能がいくつかあります。基本は英数字のコメントで検証を進めたいのですが、この機能ばかりは日本語(「なるほど!」)の描画結果を検証せざるを得ません。そのため、検証ルールを以下のように割り切っています。

  • 検証用コメントは原則英数字にする
  • どうしても日本語が要る箇所(「なるほど!」ボタンなど)だけ、日本語プロファイルに切り替える
  • それでも記号は厳しいので、「なるほど!」は を諦めて 「なるほど」で部分一致させる

固定待機の限界

コメントは一定速度で右から左へ流れるため、投稿後に一定時間待てば canvas の可視領域内にいるタイミングでスクリーンショットを撮れます。現在は waitForTimeout(2000) で「コメントがおおよそ中央付近を通過しているであろうタイミング」を狙っています。

ただし、これは「早すぎるとコメント全文がまだ画面内に収まっていない・遅すぎると画面外へ流れ去っている」という両方向のリスクを抱えた暫定策です。実際には検証に使うコメントの長さがおおむね揃っているため、実用上この固定待機が致命的な問題になることはありませんでしたが、本当は「対象コメントが可視領域の中央付近にいる」ことを条件待ちにしたいです。コード内にも TODO として残してあります。

// コメントが canvas の可視領域内に流れてくるのを待つ。
// TODO: 固定の待機時間ではなく、対象コメントが可視領域内にいることを条件待ちにしたい
await ctx.受講生1.画面.waitForTimeout(2000);

配信経路の「暖機」

固定待機と関連してもう1つ。最初のコメントは配信経路が温まっておらず反映遅延が大きいことがあるため、上記の固定待ちでは間に合わないことがありました。そこで、OCR 検証を伴わないダミーコメントを先に1件流し、経路を温めてから本番の投稿に進むようにしています。

// 初回コメントはコメント配信経路が温まっていないため反映遅延が大きく、
// canvas OCR の固定待ちでは間に合わないことがある。
// OCR 検証を伴わないダミーコメントを先に 1 件流して経路を温めておく。
const warmupText = `[E2E] warmup${Date.now() % 10000}`;
await 授業操作.コメント投稿(受講生1, warmupText);
await 受講生1.画面.waitForTimeout(5000);
// 先生ツール(授業を管理する講師用の別アプリケーション)側のコメントは
// canvas 上を流れるのではなく DOM 要素として一覧表示されるため、OCR を使わずそのまま検証できる。
await 先生ツール操作.コメントが表示されていること(ctx, warmupText, { isQuestion: false });
// 暖機フェーズでは生徒側のコメント表示は OCR 検証しない。

AI にパラメータ探索ループを回させた話

最初は読み取りが安定しなかった

デフォルト設定の Tesseract に canvas のスクリーンショットをそのまま食わせると、特に日本語の読み取りが安定しませんでした。Tesseract のチューニング軸は複数あり、互いに影響します。

  • 言語プロファイル
  • ページセグメンテーションモード(PSM。画像内のテキスト領域をどう分割して認識するかの指定)
  • user_defined_dpi(解像度の扱い)
  • tessedit_char_whitelist(読み取る文字種の絞り込み)
  • preserve_interword_spaces(空白の扱い)

パラメータの組み合わせは少なくありません。しかも素朴にやると、1パターン試すたびに「ブラウザ起動 → 授業に入る → コメント投稿 → 描画待ち → スクリーンショット → OCR」と手数がかかります。これを1つずつ手で回すのは、効率の悪い作業でした。

やったこと: 評価ループを AI に肩代わりさせる

そこで、採点できる仕組みを先に用意して、試行を AI に任せることにしました。

手順は次のとおりです。

  1. 正解データを固定する。検証したいコメント文字列と、その描画スクリーンショットをセットで用意する。
  2. 採点基準を決める。「OCR 結果を正規化して、期待文字列を部分一致で含むか」をスコアとする。
  3. AI にループを回させる。「パラメータを変える → OCR を実行 → 正解と比較 → 次の候補を提案する」を繰り返させる。
  4. 人間は方針判断をする。「日本語が要る箇所以外は英数字に倒す」といった上位の意思決定を担当し、細かいパラメータ探索は任せる。

ポイントは、正解データと採点基準を先に固定したことです。これが決まっていれば、あとは試行回数の問題に還元でき、その試行は AI に肩代わりさせられます。

さらに言うと、このAIが自走で探索できる足場自体も、「こういう採点をしたい」という意図を伝えて AI に組ませて、人間は設計レビューと方針判断に集中しました。

収束した結論(=いまのコードに残っている設定)

async function OCRプロファイル用Workerを作成する(profile: OCRプロファイル) {
  const worker = await Tesseract.createWorker(LANGUAGE_BY_PROFILE[profile], 1, {
    cachePath: OCR_CACHE_PATH,
  });

  if (profile === "ja") {
    await worker.setParameters({
      // OCR が勝手に空白を差し込んでも比較側で吸収するため、空白保持は不要。
      preserve_interword_spaces: "0",
      // 読み取り文字を絞り込みたい場合だけ有効化する。
      // tessedit_char_whitelist: "なるほど!!",
      // 小さいラベル文字は 1 文字前提の方が安定して読める。
      tessedit_pageseg_mode: Tesseract.PSM.SINGLE_CHAR,
      // 解像度不足扱いを避けるため、OCR 上は高 DPI として扱う。
      user_defined_dpi: "300",
    });
  }

  return worker;
}

ここから読み取れる結論を整理すると、次のとおりです。

  • 英数字はデフォルトのままで十分安定して読めた。比較側で正規化していた影響もあるかもしれない。そのため、検証コメントは原則英数字に倒す方針にした。
  • 特別扱いが必要だったのは日本語のときだけで、調整したのは次の3つ。
    • tessedit_pageseg_mode: PSM.SINGLE_CHAR … 小さいラベル文字は「1文字」として扱うほうが安定して読めた
    • user_defined_dpi: "300" … 解像度不足扱いを避けるため、高 DPI として扱わせる
    • preserve_interword_spaces: "0" … 空白は比較側の正規化で吸収するので、OCR 側で保持する必要はない
  • whitelist は不採用にした。最初は「なるほど!」専用のプロファイルを作ったりしていたが、パラメータ調整をした結果、ひらがなであれば十分な精度が出ることが分かったため、もう少し広い範囲で認識できる状態にした。

そして比較側でも、前述の NFKC 正規化 + 空白除去 + 小文字化 + 部分一致で、OCR の揺れを吸収しています。OCR を完璧にしにいくのではなく、OCR・テストデータ・比較ロジックの3者で歩み寄った形です。

OCR ログのスクリーンショット

このテストで保証できること / できないこと

最後に、このテストの守備範囲を正直に線引きしておきます。テストは「何を保証していないか」を明示してこそ信頼できると考えています。

保証できること

  • コメントが受講生の canvas に「見える形で」描画される end-to-end の経路が生きていること
  • 受講生1、受講生2、先生用の管理ツールの3画面で確認しているため、送信者の手元だけでなく他のクライアントや先生側にも見える形で届くこと
  • 通常コメントや質問、「なるほど!」など、各種コメント種別がそれぞれ画面へ反映されること

保証できないこと

  • 文字列の完全一致
  • 正確な描画位置やスタイル

今回の目的は配信基盤移行の回帰検知です。「移行後、コメントが受講生の画面に見える形で届かなくなる」というもっとも怖い回帰を捉えられれば、目的に対しては十分機能します。100% の精度ではなく、守りたい回帰を検知できる粒度に設計を寄せた、というのが結論です。

まとめ

  • canvas のような「ピクセルしかない UI」も、OCR を割り切って使えば E2E の検証範囲に収められる。
  • 重要なのは精度 100% ではなく、守りたい回帰を検知できる粒度に設計すること。OCR・テストデータ・比較ロジックの3者で歩み寄ることで実用ラインに届いた。
  • OCR のような、正解を採点できる問題のチューニングは、「正解データと採点基準を先に固定し、試行を AI に回させる」のが速くて再現性も高い。最終値より、収束プロセスが再現可能になったことに価値がある。

E2E と言っても DOM までを検証範囲にするテストが多く、それで十分な UI もたくさんあります。今回は守りたいものが canvas の中にあったため、ピクセルまで踏み込むことになりました。同じように DOM からは中身が見えない UI をテストしたい方の参考になれば幸いです。