puppeteerを使ったE2Eテストにマニュアル用キャプチャを組み込む

## まとめ
Puppeteer + Intro.js = E2E + Manual Capture

## モチベーション
[puppeteer](https://github.com/GoogleChrome/puppeteer)を利用するとGoogleChromeのheadlessモードを制御して簡単に画面キャプチャを取ることが出来ます。この機能を使うと[reg-suit](https://github.com/reg-viz/reg-suit)などと連携して、E2Eのテストを比較的簡単に実現できます。

ところでE2Eの本来の目的とは何でしょうか。

1. E2Eのシナリオはアプリケーション本体の仕様であり
2. その確認するための動線は実際のマニュアルに等しい

と私は考えています。
そのため、同時にマニュル用画面キャプチャも一緒に行いたいというのが今回の話の発端です。

## 話さないこと
Puppeteerの詳し使い方や、reg-suitに関してはこの記事では言及しません。
E2EをどうやってCIで行うかに関しても同様です。

## 使うツール
<img src="https://user-images.githubusercontent.com/10379601/29446482-04f7036a-841f-11e7-9872-91d1fc2ea683.png" width="200"/>
[puppeteer](https://github.com/GoogleChrome/puppeteer)は、GoogleChromeをDevToolプロトコルを通して制御するための高水準APIを提供します。
かみくだいて言うと、node.jsからGoogleChromeを簡単に操作できます。

<img width="159" alt="スクリーンショット 2017-12-21 0.05.24.png" src="https://qiita-image-store.s3.amazonaws.com/0/11214/3d630af1-0c21-7815-936a-22058a49d6d4.png">

[Intro.js](https://introjs.com/)はデモとかチュートリアル的なものを簡単に作れるライブラリです。
スポットライトのような感じで、サービスの使い方を教えてくれる機能を提供してくれます。

## 準備

### シナリオの作成
まずはpuppeteerを使ったシナリオテストがないと意味がありません。
今回は、以下のような流れのシナリオを作ります。

1. Google検索を表示
2. 検索結果の一覧からconnpassのサイトを探索
3. Burikaigi2018のエントリーサイトを表示

```capture.js
// node capture.js で実行するとactual_images/以下にそれぞれのスナプショットが保存される。
const puppeteer = require("puppeteer");

(async () => {
  // ブラウザを起動
  // わかりやすいように、ヘッドレスモードをOFFにして、各アクションに300msec遅延させる
  const browser = await puppeteer.launch({
    headless: false,
    slowMo: 300,
  });

  // ブラウザの新規ウィンドウ作成
  const page = await browser.newPage();
  // ウィンドウサイズを調整
  await page.setViewport({ width: 1600, height: 1200 });

  // Googleへ遷移し、ネットワーク通信が終わるまで待つ
  const URL = "http://google.co.jp";
  await page.goto(URL, {waitUntil: "networkidle2"});
  await page.screenshot({path: "./actual_images/1.png"});

  // 入力フィールドに検索文字列を入力
  await page.type("#lst-ib", "burikaigi2018 connpass");
  await page.screenshot({path: "./actual_images/2.png"});

  // 検索実行し検索結果が表示されるまで待つ
  await page.click("input[type=submit]");
  await page.waitFor("#resultStats");
  await page.screenshot({path: "./actual_images/3.png"});

  // 検索結果のタイトル一覧を取得し、connpass.comを含むコンテンツへ遷移
  const aHandles = await page.$$("h3 > a");
  for (var i = 0; i < aHandles.length; i++) {
    const href = await page.evaluate((handle) => handle.href, aHandles[i]);
    if (href.includes("connpass.com")) {
      await page.screenshot({path: "./actual_images/4.png"});
      await aHandles[i].click();
      break;
    }
  }

  // ナビゲーションが完了するまで待つ
  await page.waitForNavigation({ waitUntil: "networkidle2" });
  await page.screenshot({path: "./actual_images/5.png"});

  // ブラウザを閉じる
  await browser.close();
})();
```

### ヘルプ用の画像取得
上記のシナリオにヘルプ用の画像取得の処理を組み込んでいきます。
ヘルプ生成のIntro.jsを動的に読み込むメソッドを定義します。

```javascript
async function appendIntro(page, setup) {
  // Intro.jsをCDNから読み込み
  await page.evaluate(() => {
    var script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/npm/intro.js@2.7.0/intro.min.js';
    document.body.appendChild(script);

    var link = document.createElement('link');
    link.rel = "stylesheet";
    link.href = "https://cdnjs.cloudflare.com/ajax/libs/intro.js/2.7.0/introjs.min.css"
    document.body.appendChild(link);
  });

  // IntroJSがアクセス可能になるまで待つ
  await page.waitForFunction("window.introJs");

 // 対象のセレクタにIntro.jsのデータ属性を設定する
  await setup(page);
}
```

上記メソッドを、画面遷移時に読み込ませます(一部抜粋)。

```javascript
const URL = "http://google.co.jp";
await page.goto(URL, {waitUntil: "networkidle2"});
await appendIntro(page, setupGoogleTop);
```

appendIntroで定義している `setupGoogleTop` は、各セレクタにIntro.jsの設定を組み込むためのものです。
`data-step`はチュートリアルの順番、 `data-intro` は実際に表示されるツールチップを示します。
詳細は[Intro.jsのAPI](https://introjs.com/docs/intro/api/)を参照してください。

```javascript
function setupGoogleTop(page) {
  return page.evaluate(() => {
    const textInput = document.getElementById("lst-ib");
    textInput.setAttribute('data-step', 1);
    textInput.setAttribute('data-intro', '入力フィールドを選択し、burikaigi2018 connpassと入力してください');

    const submitButton = document.querySelector("input[name=btnK]");
    submitButton.setAttribute('data-step', 2);
    submitButton.setAttribute('data-intro', '検索ボタンを押します');
  });
}
```

これでIntro.jsを実行される仕組みの準備は完了です。
以下のようにメソッドを実行して画面遷移時に、チュートリアルを起動します。

```javascript
// チュートリアル開始
await page.evaluate(() => window._intro = introJs().start());
// 次のヘルプを表示
await page.evaluate(() => window._intro.nextStep());
```

完成形は[Gist](https://gist.github.com/hikaruworld/d9fdd3616689e682809d60bfd0131c33#file-capture-help-js)にUpしています。
実際に実行した場合のキャプチャは以下の通りです。

1. Googleを表示<br />
  <img src="https://qiita-image-store.s3.amazonaws.com/0/11214/288c68e1-048b-5b82-f02c-f11717b5ff64.png" width="400px"/>
1. 検索条件入力<br />
  <img src="https://qiita-image-store.s3.amazonaws.com/0/11214/fe0fec2c-1699-585e-922d-bc11ac32a30d.png" width="400px"/>
1. 検索ボタンを押す<br />
  <img src="https://qiita-image-store.s3.amazonaws.com/0/11214/a9d90f8e-0282-cc92-f5f5-f2d4e27eda49.png" width="400px"/>
1. 一覧が表示<br />
  <img src="https://qiita-image-store.s3.amazonaws.com/0/11214/a5ccf9cc-fe1b-ccd5-5434-92d2e8526973.png" width="400px"/>
1. リンクをクリック<br />
  <img src="https://qiita-image-store.s3.amazonaws.com/0/11214/048ff34f-9f57-cd00-59db-619cdcd559e7.png" width="400px"/>

## 結論
E2Eテストを作る際に頑張って取得したセレクタを、それ以外にも有効活用したいと考えて、実践した案の一つです。
この記事では、マニュアルのためのキャプチャでしたが、puppeteerはDevToolにもアクセス出来ます。
例えば、パフォーマンス測定なども組み込むことができると考えています。

## おまけ

#### Q. 直接Intro.jsだけ読み込んで、リリース時だけ外せばいいんじゃね?
はい、その通りです。

#### Q. Intro.jsのプレフィックス最初からつけとけばいいんじゃね?
はい、その通りです。

#### Q. キャプチャだけ取れてもマニュアルじゃないよね?
data-step/data-introの部分を外部化して生成出来るように考えてますがまだやっていません。

#### Q. 完成系のGist見ると、E2E用のキャプチャが消えてるんだけど?
コードを単純化するために、一旦マニュアルのためのキャプチャ以外の要素は消しました。

## 参考
* [GoogleChrome/puppeteer](https://github.com/GoogleChrome/puppeteer)
* [ヘッドレス Chrome ことはじめ](https://developers.google.com/web/updates/2017/04/headless-chrome?hl=ja)
* [Intro.js - API](https://introjs.com/docs/intro/api/)