ABEMAでスナップショットテストをやめてVisual Regression Testingに移行する話

こんにちは、ABEMA でフロントエンドエンジニアをしている李です。
今回は ABEMA Web でスナップショットテストをやめ、Visual Regression Testing (以下 VRT と略す)に移行する話を紹介したいと思います。

移行するモチベーション

2020年6月から ABEMA Web でテストを自動化するプロジェクトを始め、単体テストのカバー率を上げるため、 Storybook ベースの VRT を導入することが決まり、それをきっかけに、既存のテストも整理することになりました。

ABEMA Web での UI 周りのテストは主に以下の3つがあります。

  1. AVA + react-test-renderer/@testing-library/react + sinon によるテスト
  2. AVA によるスナップショットテスト
  3. Storybook ベースの VRT

整理したらスナップショットテストと VRT が被った部分が存在することに気付きました。

  • 両方とも UI の予期せぬ差分を検出するためのテストで、機能的に被っている
  • テストケースが同じだが、テストコードを二回書く必要があり、コード的に被っている

また、既存のスナップショットテストには以下の問題があります。

  • スナップショットは HTML ベースの差分で、CSS 周りの差分が含まれず、UI の変化が確認できない
  • AVA でのスナップショットテストにバイナリファイルが生成され、大人数の開発ではコンフリクトが生じやすい
  • Pull Request のレビュー時、差分が大量発生する場合はスナップショット差分のレビュー難易度が高く、見落としがちである

一方、スナップショットテストでは見た目に表れない HTML attributes も確認できますが、これは VRT ではカバーできません。それに関してはこちらの方法でカバーすることにしました。

  • 短期的には、重要な部分だけ react-test-renderer や @testing-library/react で担保する
  • 長期的には、アクセシビリティ関連の属性については acot での導入なども検討する予定

上記の問題と対策を踏まえて、スナップショットテストをやめ、VRT に移行することにしました。

移行後の姿

今回使用したライブラリは主にこちらになります。

  • storybook
  • storycap
  • reg-suit
  • reg-notify-github-plugin
  • reg-publish-gcs-plugin
  • reg-simple-keygen-plugin

アーキテクチャはこういうイメージです。

アーキテクチャ図

GitHub で Pull Request が作られ、Circle CI 上 reg-suit でスクリーンショットを取り、 GCS にアップロードをします。また、Pull Request のベースから比較対象の hash 値を取得し、 GCS よりその hash 値のスクリーンショットをダウンロードし、VRT を行います。

VRT の結果はこのように GitHub にコメントされます。

カスタマイズ後のコメントイメージ

GitHubで Slack の webhook が設定されており、Slack 経由でも通知が届き、Pull Request 作者が reg-suit で提供された viewer で結果を確認し、問題なければ上記のコメントにチェックをつけます。Pull Request のレビュアーは通常差分のレビューを行わなくてもいいです。

viewer はこういう感じです。

reg-suit が提供したビューアー

Pull Request が問題なくマージされたら、CircleCI でもう一回スクリーンショットを取り、GCS にアップロードすることになります。

移行中遭遇した問題

大きめな移行には必ず色々な問題に遭遇します。ABEMA Web も例外ではありません。ここで我々が遭遇した問題をいくつか抜粋し、その解決方法を紹介したいと思います。

外部リソースより取得する画像が不安定

ABEMA Web ではコンテンツのサムネイルはすべて外部リソースより取得する画像で、通信状態により、VRT で撮ったスクリーンショットにズレが発生し、結果も不安定になります。

リポジトリに Storybook 用の画像をパターン分用意する方法もありますが、ABEMA Web でパターンが多いためその方法は現実的ではありません。対策を考えていた時、 Placeholder Image に辿り着き、それを参考にして Canvas でダミー画像生成のアプローチを取りました。

実際のソースコードはこちらになります。

const FILL_STYLE = 'lightgray' as const;
const STROKE_STYLE = 'gray' as const;
const FONT_COLOR = 'black' as const;
const FONT = 'sans-serif' as const;

type Params = {
  width: number;
  height: number;
  text?: string;
};

function getMaximumFontFize({ width, height, text = '' }: Params): number {
  const maxHeight = Math.floor(height / 2);
  const maxWidth = Math.floor(width / text.length);

  return Math.min(maxHeight, maxWidth);
}

function fillTextAtCenter({
  ctx,
  height,
  width,
  y,
  text,
}: {
  ctx: CanvasRenderingContext2D;
  height: number;
  width: number;
  y: number;
  text: string;
}): void {
  const fontSize = getMaximumFontFize({ width, height, text });

  ctx.font = `${fontSize}px ${FONT}`;
  ctx.fillText(text, width / 2 - ctx.measureText(text).width / 2, y, width);
}

export function generatePlaceholderImage({ width, height, text }: Params): string | null {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    return null;
  }

  document.body.appendChild(canvas);

  canvas.width = width;
  canvas.height = height;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = FILL_STYLE;
  ctx.fillRect(0, 0, width, height);

  ctx.strokeStyle = STROKE_STYLE;
  ctx.strokeRect(0, 0, width, height);

  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(width, height);
  ctx.moveTo(width, 0);
  ctx.lineTo(0, height);
  ctx.stroke();

  ctx.fillStyle = FONT_COLOR;

  fillTextAtCenter({ ctx, height, width, y: height - 5, text: `${width}x${height}` });

  if (text) {
    fillTextAtCenter({ ctx, height, width, y: height / 2 - 5, text });
  }

  const imageData = canvas.toDataURL();
  canvas.remove();
  return imageData;
}

それを Storybook の環境で呼び出し、画像をダミーで上書きします。

if (process.env.STORYBOOK_ENABLE === 'true') {
  const url1x =
    generatePlaceholderImage({
      width: option.width,
      height: option.height,
    }) ?? '';
  const url2x =
    generatePlaceholderImage({
      width: option.width * 2,
      height: option.height * 2,
    }) ?? '';
  return {
    '1x': url1x,
    '2x': url2x,
  };
}

上書き後はこういう感じになります。

差し替え後のダミー画像

ダミー画像に統一したことによって、外部リソースから取得する時のタイムラグがなくなり、また画像が簡単になることで差分検出の難易度も下がり、精度が上がりました。

VRT でスクリーンショットの差分判定時にノイズが多い

VRT を導入した初期は画像差分比較時のノイズで UI 変更がなくても変更ありの結果が出ていました。そのせいで目視で確認することが増え、効率が下がることにも繋がり、そのため差分判定のノイズ解消に力を入れました。

我々が取った対策は主にこちらになります。

  • Storybook で時間の扱いをすべて固定にする
    • 時間の変動による日時表記の差分があったため、時間を固定することにより解決できた
  • 特定のコンポーネントでのスクリーンショットに遅延を入れた
    • 特定のコンポーネントのレンダリングに時間がかかり、スクリーンショットを取った時差分ができてしまうことがあり、遅延を入れることでレンダリング済みの状態になり、差分がなくなりました。
  • enableAntialias を true にする
    • スクリーンショットの曲線部分はジャギーが発生するため、アンチエイリアス処理を有効にすることで、パフォーマンスが落ちることがあるが、その分のノイズを取り除くことができる。また、ジャギーによるノイズが目立つコンポーネントだけに制限することで、パフォーマンスと効果両方得ることができました。

通知のカスタマイズ

ABEMA Web では VRT の結果通知をカスタマイズしています。 reg-notify-github-plugin は CircleCI のジョブ実行結果だけに使用しています。理由は主に3つです。

  1. フォーク前提の開発で結果コメントがうまく GitHub に反映できない
  2. デフォルトの通知に色付きの丸が大量に表示され、可読性に欠ける
  3. 通知が大量に届く

フォーク前提の開発で結果コメントがうまく GitHub に反映できない

reg-notify-github-plugin がフォークの Pull Request をサポートしておらず、カスタマイズせざるをえないです。
https://github.com/reg-viz/reg-suit/issues/159

デフォルトの通知に色付きの丸が大量に表示され、可読性に欠ける

reg-notify-github-plugin のデフォルト通知はこういうイメージです。

デフォルトのコメントイメージ

ABEMA Web でコンポーネント数が 1000 を超えているため、コメントが丸だらけになります。そのせいで可読性に問題があります。こういうイメージにカスタマイズをしました。

カスタマイズ後のコメントイメージ

カスタマイズにより、情報がまとめられており、分かりやすくなりました。

通知が大量に届く

導入した初期はコミットが更新される度にコメントが追加され、ABEMA Web みたいな大人数開発にはまさに通知の嵐でした。そのせいで有意義なコメントが見落としがちになってしまいました。それを解決するため、通知をカスタマイズし、都度上書きするように修正しました。

また、差分が確認されたかどうかをわかりやすくため、チェックボックスを追加し、Pull Request 作者が差分を確認した上でチェックをつけることでレビュアーの負担を減らすことができました。

コメントのカスタマイズコードはこちらになります。

const fs = require('fs');
const path = require('path');

const fetch = require('node-fetch');

const {
  CIRCLE_SHA1,
  CIRCLE_PULL_REQUEST,
  CIRCLE_PROJECT_USERNAME,
  CIRCLE_PROJECT_REPONAME,
  CIRCLE_PR_NUMBER,
  GITHUB_REPOSITORY_ACCESS_TOKEN,
} = process.env;

if (!CIRCLE_PR_NUMBER && !CIRCLE_PULL_REQUEST) {
  console.log('VRT結果コメントの追加先Pull Requestがありません');
  process.exit(0);
}

const wordToJudgeIsRegReport = 'Storybook スクリーンショットに差分';

function createRegReport() {
  const regOutJsonStr = fs.readFileSync(path.join(__dirname, '../.reg/out.json'), 'utf-8');
  const regOutJson = JSON.parse(regOutJsonStr);

  const newItemCount = regOutJson.newItems.length;
  const diffItemCount = regOutJson.diffItems.length;
  const deletedItemCount = regOutJson.deletedItems.length;
  const passedItemCount = regOutJson.passedItems.length;

  const regConfigJsonStr = fs.readFileSync(path.join(__dirname, '../regconfig.json'), 'utf-8');
  const regConfigJson = JSON.parse(regConfigJsonStr);

  const regCustomUri = regConfigJson.plugins['reg-publish-gcs-plugin'].customUri;
  const regCustomPathPrefix = regConfigJson.plugins['reg-publish-gcs-plugin'].pathPrefix;

  if (newItemCount === 0 && diffItemCount === 0 && deletedItemCount === 0) {
    return `${wordToJudgeIsRegReport}はありません :sparkles:`;
  }

  return `${wordToJudgeIsRegReport}があります。
:white_circle: ${newItemCount} items added
:red_circle: ${diffItemCount} items changed
:black_circle: ${deletedItemCount} items deleted
:large_blue_circle: ${passedItemCount} items passed
[レポート](${regCustomUri}/${regCustomPathPrefix}/${CIRCLE_SHA1}/index.html)を確認してください。
Permanアカウント(\`user.xxxx_xxxx@cloud-identity.perman.jp\`)の認証となります。 :bow:
- [ ] 差分に問題ないことを確認しました`;
}

async function postOrUpdateReportCommentToPr(regReport) {
  /**
   * `CIRCLE_PR_NUMBER` はフォークされた PR でのみ使用できます
   * そのため、`CIRCLE_PULL_REQUEST` から抽出します
   * 詳細 https://circleci.com/docs/ja/2.0/env-vars/
   */
  const pullRequestNumber = CIRCLE_PR_NUMBER || /pull\/(\d+)$/.exec(CIRCLE_PULL_REQUEST)[1];

  const requestHeaders = { Authorization: `token ${GITHUB_REPOSITORY_ACCESS_TOKEN}` };

  const fetchCommentsResult = await fetch(
    `https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${pullRequestNumber}/comments`,
    { headers: requestHeaders },
  ).catch(() => ({ ok: false }));

  const comments = fetchCommentsResult.ok ? await fetchCommentsResult.json() : [];
  const regReportComments = comments.filter(({ body }) =>
    new RegExp(`^${wordToJudgeIsRegReport}`).test(body),
  );
  const commentIdToUpdate = regReportComments[regReportComments.length - 1]?.id;

  // 既存レポートない場合、新規コメントを追加
  if (commentIdToUpdate === undefined) {
    console.log(`${CIRCLE_PULL_REQUEST} にVRT結果コメントを追加します`);

    try {
      const postCommentResult = await fetch(
        `https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${pullRequestNumber}/comments`,
        {
          method: 'post',
          body: JSON.stringify({ body: regReport }),
          headers: requestHeaders,
        },
      );

      if (!postCommentResult.ok) {
        throw new Error(postCommentResult.statusText);
      }

      console.log('コメント追加成功しました');
    } catch (error) {
      console.error('コメント追加失敗しました', error);
    }

    return;
  }

  // 既存レポートがある場合、既存コメントを更新
  console.log(`${CIRCLE_PULL_REQUEST} のVRT結果コメントを更新します`);

  try {
    const patchCommentResult = await fetch(
      `https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/comments/${commentIdToUpdate}`,
      {
        method: 'patch',
        body: JSON.stringify({ body: regReport }),
        headers: requestHeaders,
      },
    );

    if (!patchCommentResult.ok) {
      throw new Error(patchCommentResult.statusText);
    }

    console.log('コメント更新成功しました');
  } catch (error) {
    console.error('コメント更新失敗しました', error);
  }
}

postOrUpdateReportCommentToPr(createRegReport()).catch((error) => {
  console.error('VRT 結果通知失敗しました', error);
});

モバイルブラウザ用の設定を都度書く必要があり、手間がかかる

ABEMA Web ではモバイルブラウザの対応もされており、React の Context で判断しており、Storybook で毎回Provider で書く必要があり、すごく手間がかかるので、下記のように共通の decorator を用意し、省けるようにしました。

export const decorators = [
  // storycap
  withScreenshot,
  (Story) => {
    const [isMobileViewportBeforeResized, setIsMobileViewportBeforeResized] = useState(
      isMobileViewport(),
    );

    const resizeHandler = useCallback(() => {
      /**
       * @storybook/addon-viewport の mobile viewport に設定したか、
       * mobile viewport 設定から離脱した場合、
       * `loadCSS` を実行させるため、 ページをリロードさせます
       */
      const mobileViewport = isMobileViewport();

      if (
        (!isMobileViewportBeforeResized && mobileViewport) ||
        (isMobileViewportBeforeResized && !mobileViewport)
      ) {
        window.location.reload();
        setIsMobileViewportBeforeResized(true);
      }
    }, []);

    useEffect(() => {
      fromEvent(window, 'resize').pipe(debounceTime(1000)).subscribe(resizeHandler);
    }, []);

    return isMobile() ? (
      <DeviceTypeContextProvider value={DEVICE_TYPE_MOBILE}>
        <div className="adapt-mobile">
          <Story />
        </div>
      </DeviceTypeContextProvider>
    ) : (
      <Story />
    );
  },
];

また、VRT でスクリーンショットを取る時の viewport とローカル確認時のデフォルト viewport も都度書く必要があっため、こちらのように共通の設定として用意しました。

js
export const MOBILE_STORY_CONFIG =
  process.env.VRT === 'true'
    ? // CI で VRT を実行する場合、スクショ撮る時使う viewport を指定
      {
        parameters: {
          screenshot: {
            viewport: 'iPhone X',
          },
        },
      }
    : // local で storybook を立ち上げる場合、`@storybook/addon-viewport` の `defaultViewport` を指定
      {
        parameters: {
          viewport: {
            defaultViewport: 'mobileM',
          },
        },
      };

おわりに

今回 ABEMA Web でスナップショットテストをやめ、VRT に移行することになりました。そのおかげで開発者が差分レビューから解放され、より開発に集中できるようになりました。デグレーションの心配もなくなり、より気楽にリファクタリングを行えるようになりました。もし同じ問題を抱えていたら参考になれればと思います。