JestとPuppeteerでお手軽(Visual)レグレッションテスト

# はじめに

初期のスタートアップでのリリース前の最低限のテストとしてJestとPuppeteerを使ってVisualレグレッションテストと挙動のレグレッションテストを作りました。備忘も兼ねてまとめてみます。

# 今回使うツール

主に使うものは以下の2点です。PuppeteerとJest自体の説明は割愛します。

## jest-puppeteer

2018年3月にリリースされたJestとPuppeteerを連携するためのツールです。

* https://github.com/smooth-code/jest-puppeteer/

Jestのテスト内でPuppeteerのpageオブジェクトが使えるようになります。また[いくつかのmatcher](https://github.com/smooth-code/jest-puppeteer/blob/master/packages/expect-puppeteer/README.md#api)が追加されます。

### 例

```javascript
// フォームを埋める。対象のフォーム及びフィールドが見つからない場合はテストが失敗する
await expect(page).toFillForm('form[name="login-form"]', {
  email: 'test@example.com',
  password: 'password',
})

// Loginというテキストのボタンをクリックする。対象のボタンが見つからない場合はテストが失敗する
await expect(page).toClick('button', { text: 'Login' })
```

## jest-image-snapshot

AmericanExpress製のVisualレグレッションテストツールです。

* https://github.com/americanexpress/jest-image-snapshot/

### 例

```javascript
// jest-puppeteerにより利用可能になったpageオブジェクトを使ってスクリーンショットを撮る
await page.goto("https://example.com/target/page", { waitUntil: WAIT_UNTIL });
const image = await page.screenshot();

// 今回撮ったスクリーンショットが以前撮ったスクリーンショットとマッチするかテストする
expect(image).toMatchImageSnapshot();
```

スクリーンショットが一致しない場合はテストを失敗させた上でsnapshot保存先ディレクトリ(デフォルトは`<PROJECT_ROOT>/snapshots`)内の`__diff_output__`というディレクトリ内に以下のようなファイルを生成してくれます。

![-programs-diff.png](https://qiita-image-store.s3.amazonaws.com/0/36003/5bbb7834-acc7-8b24-fdd3-0e0470262384.png)


# 手順

## インストール

まずは必要なものをインストールします。

```bash
yarn add -D jest puppeteer jest-puppeteer jest-image-snapshot
```

## 設定ファイルの作成

まずはPuppeteerの設定を記述するファイルを作成します。

```javascript:jest-puppeteer-config.js
module.exports = {
  launch: {
    // デバッグ時は以下をuncommentします
    // headless: false,

    // デフォルトのChromiumで動かない機能がある場合はChromeの実行ファイルパスを渡すこともできます
    // executablePath: "/usr/bin/google-chrome-stable",

    // 必要に応じて引数を渡します
    args: ["--start-maximized", "--no-sandbox"],
  },
};
```

次にJestの設定ファイルを作成します。`preset`以外は環境に合わせて適宜設定してください。

```javascript:jest.config.js
module.exports = {
  preset: "jest-puppeteer",
  globalSetup: require.resolve("./globalSetup.js"),
  setupTestFrameworkScriptFile: require.resolve("./setup.js"),
  rootDir: process.cwd(),
  testEnvironment: "<rootDir>/__tests__/NgtTestEnvironment.js",
  modulePathIgnorePatterns: [
    "<rootDir>/__tests__/utils",
    "<rootDir>/__tests__/NgtTestEnvironment.js",
  ],
};
```

`setup.js`では以下のように`jest-image-snapshot`と`expect-puppeteer`(jest-puppeteer内のパッケージ)のmatherが使えるようにします。

```setup.js
const { toMatchImageSnapshot } = require("jest-image-snapshot");
expect.extend({ toMatchImageSnapshot });
module.exports = require("expect-puppeteer");
```

`globalSetup.js`では環境変数の読み込みを行ってます。これは必須ではないので環境に合わせて適宜設定してください。自分の場合は解像度別にテストを分けてるのでその値を読み込んでいます。

```globalSetup.js
const { TEST_ENV } = process.env;
const { setup: setupPuppeteer } = require("jest-environment-puppeteer");
const dotenv = require("dotenv");
module.exports = async () => {
  await setupPuppeteer();
  dotenv.config({ path: require.resolve("./.env") });
  dotenv.config({
    // TEST_ENVにはdesktop/phoneなどの値が渡る
    path: require.resolve("./environments/" + TEST_ENV + ".env"),
  });
};
```

`jest.config.js`で指定した`NgtTestEnvironment.js`では以下のように`jest-environment-puppeteer`(`jest-puppeteer`内のパッケージ)をextendsしたクラスを作り、pageの共通系の処理を書いています。これも必須ではありません。


```javascript:NgtTestEnvironment.js
const PuppeteerEnvironment = require("jest-environment-puppeteer");
const { PAGE_WIDTH, PAGE_HEIGHT, IS_MOBILE, HAS_TOUCH, IS_LANDSCAPE } = process.env;

class NgtTestEnvironment extends PuppeteerEnvironment {
  async setup() {
    await super.setup();
    await this.global.page.setViewport({
      width: parseInt(PAGE_WIDTH, 10),
      height: parseInt(PAGE_HEIGHT, 10),
      isMobile: !!IS_MOBILE,
      hasTouch: !!HAS_TOUCH,
      isLandscape: !!IS_LANDSCAPE,
    });
  }

  async teardown() {
    await super.teardown();
  }
}

module.exports = NgtTestEnvironment;
```

## Visualレグレッションテスト

前置きが長くなりましたが、あとはひたすらテストを書いていきます。VisualレグレッションテストはURL一覧を作って対象のページをテストしていくだけでできます。

まずはページ一覧のファイルを作ります。

```csv:page-list.csv
page
/page1
/page1/subpage1
```

続いてユーザ一覧を作ります。これは一般的にadminユーザや一般ユーザ、またユーザのコンテキストに応じて画面の表示内容が変わるためです。

```csv:user-list.csv
type,email,password
user type1,user1@example.com,password1
user type2,user2@example.com,password2
```

そしてあとはこれらのページ・ユーザに対して以下のようにループでテストを回すだけです。

```javascript:app.test.js
// 前述のcsvからユーザとページ一覧を読み込む。具体的な処理は割愛。
const { users, pages } = init(
  "/scripts/data/visual/user-list.csv",
  "/scripts/data/visual/auth-page-list.csv",
);

// 取得したユーザ数とページ数から必要に応じてタイムアウトを設定
// jest.setTimeout(users.length * pages.length * TIMEOUT_PER_PAGE);

// ユーザごとのループ
for (let user of users) {
  // ユーザタイプ別にスナップショット保存先を分ける
  const snapshotsDir = snapshotsBaseDir + "/" + user.type.replace(/ /g, "-");
  describe(user.type, async () => {
    beforeAll(async () => {
      // ログイン処理(省略)
      await login(user);
    });
    afterAll(async () => {
      // ログアウト処理(省略)
      await logout();
    });
    // ページごとのループ
    for (let { page: p } of pages) {
      it(p, async () => {
        // Puppeteerのpage APIを使ってページ遷移
        await page.goto(APP_BASE + p, { waitUntil: WAIT_UNTIL });
        // スクリーンショットを取得
        const image = await page.screenshot({ fullPage: false });
        // スクリーンショットを比較
        expect(image).toMatchImageSnapshot({
          customSnapshotsDir: snapshotsDir,
          // URLに含まれるスラッシュをダッシュに変換
          customSnapshotIdentifier: p.replace(/\//g, "-"),
          // 一定のパーセント(またはピクセル)以下は失敗と見なさないように設定
          failureThreshold: SNAPSHOT_FAILURE_THRESHOLD,
          failureThresholdType: "percent",
        });
      });
    }
  });
}
```

## 挙動のレグレッションテスト

こっちはjest-puppeteerが提供する[API](https://github.com/smooth-code/jest-puppeteer/blob/master/packages/expect-puppeteer/README.md#api)を利用してゴリゴリ書いていくだけなので省略します。
残念ながらSeleniumのようにレコーディングしたりするツールや拡張機能は今の所ないみたいです。(あったら教えてください)

# 注意点

## アプリを書くとき

アプリを書くときは可能な限り`data-test`という属性を使ってレグレッションテストから要素を選択できるようにしておきます。

* 参考
    * https://qiita.com/akameco/items/519f7e4d5442b2a9d2da
    * https://kentcdodds.com/post/use-data-test/

## 遭遇したトラブル

### CentOS上で動かない

EC2上のテスト専用マシンで動かないことがありましたが、[こちら](https://github.com/GoogleChrome/puppeteer/issues/391)を参考にして解決出来ました。具体的には以下の2点で対応できました。

* Puppeteerのlaunchオプション(今回の場合は`jest-puppeteer.config.js`の中に記載)に`args: ["--no-sandbox"]`を追加
* 以下をインストール

```
sudo yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y
```

### パラレルにテストを走らせられない

これは今のところPuppeteerが[contextをサポートする](https://github.com/GoogleChrome/puppeteer/issues/85)まではJestとPuppeteerのインスタンスを複数立ち上げるしかないと思ってます。

# 最後に

ということで、今回の仕組みを導入することによりURLがステートレスになっているアプリならURLを並べておくだけでVisualレグレッションテストができるようになります。既存のアプリやテストでJestやPuppeteerを使っていればかなりお手軽に導入できると思います。(ただしマルチデバイス・マルチブラウザ対応する場合は別のツール推奨です)

なお、弊社では複数のサイト・アプリで共通的に利用するコンポーネント用のプロジェクトもあり、そちらはenzymeのsnapshotテスト(とstorybookを使ったデザイナーとの認識合わせ)のみを行っています。そして実際のアプリや各サイトは今回紹介した仕組みでVisualレグレッションテストを行っています。