WebアプリにVisual Regressionテストを導入

こんにちは、CyberAgentで内定者アルバイトをしています @junkisai です。
今回は「[こえのブログ](https://voice.ameba.jp/)」の開発に導入したVisual Regressionテストについてお話させていただきます。

# そもそもVisual Regressionテストとは

コンポーネントやページのスクリーンショットを以前のバージョンのものと比べて、ピクセルレベルでの差分を検出するテスト手法のことです。導入の目的としては、開発者が意図しない変更がないか/差分が意図したものかをテスト結果から確認するだけにすることで、人によるチェックの負担を軽減するためというのが最も大きいですが、機能や動作を検証するためのテストとしても有効です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244544/72063a92-0f2b-2761-8fb9-2fbd71bfc9a8.png)


# 完成品

今回導入したVisual Regressionテストの流れ図は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244544/221f0222-4669-230d-978f-b4fed783c366.png)

GitHubにpushすると、CircleCI上で`puppeteer`を起動し、Webアプリの各ページのスクリーンショットを撮影します。その後、`jest-image-snapshot`を使って、撮影された画像とmaster branchの画像との間に差分がないか検証します。差分が検知された場合は、エラーを報告し、CircleCiのArtifacts内に検証結果画像が保存されます。なお、比較元のmaster branchの画像は、リリースされるタイミングでアップデートしています。


### 使用したツール

- [puppeteer | github.com](https://github.com/GoogleChrome/puppeteer)
- [jest-image-snapshot | github.com](https://github.com/americanexpress/jest-image-snapshot)
- [jest-puppeteer | github.com](https://github.com/smooth-code/jest-puppeteer)

# キツかったところ

## 1. 下準備が大変

前述した通り、Visual Regressionテストはスクリーンショットを撮影して検証します。そのため、APIから取得するリソースが変わるとデザイン的に問題ない（本来落ちるべきではない）ケースでテストが通らなくなります。

この問題は、APIのスタブサーバーを作成し、テスト実行時のみスタブサーバーにリクエストするように制御することで同じリソースで検証することができます。しかし、複数プロセスをいい感じに実行する必要があり（APIのスタブサーバー起動→アプリケーションサーバー起動→テスト実行）、scriptが複雑になってしまいました。

```json:package.json
{
  "scripts": {
    // スタブサーバーの起動とVisualテストを同時実行
    "serve-snapshot": "run-p start:test-server -r snapshot",
    
    // APIのスタブサーバー起動(PORT=8082)
    "start:test-server": "npm run serve:stub", 
    
    // http://localhost:8082/stubApi/entriesが使えるようになるまで、テストの実行を待機
    "snapshot": "wait-on http://localhost:8082/stubApi/entries && jest -c test/visual/jest.config.js"
  }
}
```

```javascript:test/visual/jest.config.js
module.exports = {
  preset: 'jest-puppeteer',
  testRegex: './*\\.test\\.js$',
};
```

```javascript:jest-puppeteer.config.js
module.exports = {
  server: {
    command: `npm start`, // アプリケーションサーバー起動
    port: 8081,
    debug: true,
  }
};
```

※上記のコードは[2番](https://qiita.com/drafts/016c567a9fbade08d65b/edit#2-%E3%82%B9%E3%82%AF%E3%82%B7%E3%83%A7%E3%81%AE%E7%B5%90%E6%9E%9C%E3%81%8C%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%E7%92%B0%E5%A2%83%E3%81%AB%E4%BE%9D%E5%AD%98%E3%81%99%E3%82%8B)で紹介するpuppeteerの`setRequestInterception`を使用すると、APIのスタブも複雑なscriptも書くことなく、同様の要件を実現できるかと思います。



## 2. スクショの結果がネットワーク環境に依存する

ネットワーク環境が悪かったり、CORBの問題からかCircleCI環境から画像の取得ができず、一貫したスクリーンショットが撮れないという問題が起こりました。そこでpuppeteerの`setRequestInterception`を使用し、スクショの結果がネットワーク環境に依存しない工夫を施しました。

puppeteerの数ある機能の中に、リクエストをインターセプトしてハンドリングしてしまう[機能](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagesetrequestinterceptionvalue)が提供されています。この機能に関してはGoogle I/O'19にて紹介されていたので、詳しく知りたい方は以下の動画をご覧ください。

[![Modern Web Testing and Automation with Puppeteer (Google I/O’19) | youtube.com](http://img.youtube.com/vi/MbnATLCuKI4/0.jpg)](http://www.youtube.com/watch?v=MbnATLCuKI4?start=1135)

この機能を利用して、assetsにリクエストを飛ばそうとしている場合はインターセプトしてローカル内の画像を返してあげることで、安定して同一のスクリーンショットを撮影することができるようになりました。

```javascript
const getBufferImg = () => {
  const IMG_URL = `${__dirname}/mamehiko.jpg`;
  return fs.readFileSync(IMG_URL);
};

await page.setRequestInterception(true);
page.on('request', request => {
  if (
    request.resourceType() === 'image' &&
    request.url().indexOf('http://localhost:8082/assets') !== -1
  ) {
    request.respond({
      contentType: 'image/jpeg',
      body: mockImg,
    });
  } else {
    request.continue();
  }
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244544/5a651f00-8893-ccdc-7278-7c607c75634e.png)


![mamehiko.jpg](https://stat.ameba.jp/user_images/20190620/01/shiba-mamehiko/fe/22/j/o0680045314473312800.jpg)

（画像は[こちらのブログ](https://ameblo.jp/shiba-mamehiko/)から使用許可をいただきました。ありがとう、マメヒコくん。）

## 3. 画像の遅延読み込み対策

「[こえのブログ](https://voice.ameba.jp/)」は画像遅延読み込みに対応しています。puppeteerの仕様上でフルページのスクリーンショットを撮ろうとすると、ロード仕切れていない画像が表示されて、正しく撮影できない問題がありました。

この問題は[issues](https://github.com/GoogleChrome/puppeteer/issues/338
)で報告されており、1度ページの底部までスクロールさせることで解決できるようなので下記のコードで対応しました。

```javascript
const scrollToBottom = async page => {
  await page.evaluate(() => {
    let lastScrollTop = document.scrollingElement.scrollTop;
    const scroll = () => {
      document.scrollingElement.scrollTop += 400;
      if (document.scrollingElement.scrollTop !== lastScrollTop) {
        lastScrollTop = document.scrollingElement.scrollTop;
        requestAnimationFrame(scroll);
      }
    };
    scroll();
  });
};

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  /** 中略 **/
  await scrollToBottom(page);
  await page.waitFor(2000); // ページ底部の遅延読み込みを待機する
  await page.screenshot({ fullPage: true });
});
```

## 4. CircleCIに組み込む際に立ちはだかった壁

CircleCI上でpuppeteerを起動すると、日本語に対応していないためスクリーンショットが豆腐で一杯になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244544/0b69b7af-ba37-b4f8-540f-e4cbc2fa907f.png)

test実行前に日本語のフォントをインストールすることで、豆腐問題は解決することができました。しかしこの対応により、ローカルの環境で使用しているフォントとCircleCI上で使用しているフォントが違うことから、ローカルとCircleCIとでテストに使用する比較元の画像を切り分ける必要が出てきました。

そこで、CircleCIに比較元画像をキャッシュし、CI上でテストする際はキャッシュされた画像を参照することでローカルとCI環境の切り分けを行いました。

```yml:.circleci/config.yml
version: 2.0
# 中略
jobs:
  visual-test:
    <<: *defaults
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Generate _version with version in lerna.json
          command: node -pe 'require("./lerna.json").version' > _version
      - restore_cache:
          name: Restore __image_snapshots__ cache
          key: image_snapshots-{{ checksum "_version" }}
          paths:
            - test/visual/__image_snapshots__/
      - run:
          name: Install japanese font
          command: sudo apt-get update && sudo apt-get install fonts-takao-gothic
      - run:
          name: Visual Regression Testing
          command: npm run serve-snapshot
      - store_artifacts:
          path: test/visual/__image_snapshots__/
```

# 終わりに

ページごとのVisual Regressionテストに関する記事が少なく、ゴールに到達するまで苦労しましたが、無事「[こえのブログ](https://voice.ameba.jp/)」にこのテストを導入し、開発者の心理的安全の一助になれたかなと思います。またこの方法はフレームワークに依存しない上、puppeteerがなんでもできるおかげで下準備の敷居が下がっているため、導入しやすい方法なんじゃないかなと思っています（技術スタックの移行時とか効果を発揮しそう）。欲を言えば、差分検出結果をGithubやslack上に表示してあげるところまでやりたいところでした。