Cucumber × Puppeteer × chai でBDD開発におけるE2Eテスト実行環境の構築

E2Eテストの環境構築、及びサンプル実装を行いました。

# E2Eテストとは
エンドユーザーがサービスを利用する視点でのテストで、システム稼働時に近い形で動かします。
今回はWebを想定し、ブラウザで動くE2Eテスト環境を構築しました。

参考URL
[E2Eテスト(インテグレーションテスト)の利点と不利点](https://qiita.com/NAKKA-K/items/58d6b8476a3717179bda)

※今回はテストの一部で下記のページを使用しています。
https://www.nginx.com/welcome-to-nginx/

# 使用ツール
## Cucumber.js
BDD(ビヘイビア駆動開発)フレームワークのCucumberを扱うjsライブラリ。
featureと呼ばれるファイルにエンドユーザーが行う振る舞いを書いて、その振る舞いをベースにシステムが実行されていくものです。
Gherkinという言語を使って振る舞いを記述します。
GitHub: https://github.com/cucumber/cucumber-js

BDDとは？という方は下記の記事あたりが参考になるかも知れません。
https://www.atmarkit.co.jp/ait/articles/1403/25/news033.html

## Puppeteer
Chrome及びHeadless Chromeを操作するライブラリ。
Seleniumと似たようなものですが、PuppeteerはWeb Driverを別途でインストールする必要がありません。
GitHub: https://github.com/puppeteer/puppeteer

## chai
テストのアサーション用ライブラリ。
今回は特定のHTMLタグ内の文字列が想定したものであるかどうかのチェックで使います。
GitHub: https://github.com/chaijs/chai

# 環境構築手順
## Node.jsのバージョン切り替え
nodebrewを使用し、v13.9.0にしました。

```
$ nodebrew install v13.9.0
$ nodebrew use v13.9.0
```

## package.json作成
今回はパッケージインストールにyarnを使用します。

```
$ yarn init
```

## 各種パッケージインストール
puppeteer、cucumber、chaiを入れます。

```
$ yarn add puppeteer --dev
$ yarn add cucumber --dev
$ yarn add chai --dev
```

## 実行コマンドエイリアス追加
package.jsonにcucumberからのテスト実行用のコマンドを追加します。

```package.json
{
  "name": "e2e_test",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "e2e_test": "./node_modules/.bin/cucumber-js" // 追加
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "cucumber": "^6.0.5",
    "puppeteer": "^2.1.1"
  }
}
```

# E2Eテスト対象用のサーバーを起動
今回はサンプルとしてnginxのサーバーを立ち上げます。
デフォルトのページを表示し、そのページ及び遷移後のページを使用したE2Eテストを実行します。
別のshellから、Dockerでサーバーを起動。

```
$ docker run -p 8080:80 nginx
```

## 画面表示
http://localhost:8080 にアクセスします。
![スクリーンショット 2020-02-29 0.20.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154121/af2b22ac-ce7a-6843-9923-076451a59e83.png)
nginxの初期画面が表示されました。

「nginx.com」をクリックすると、下記のように公式ページに飛びます。
![スクリーンショット 2020-02-29 0.22.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154121/3162c24b-c20b-ef51-2fcd-1306dcac9b3e.png)

今回はここまでの動作を、エンドユーザーが行う動作としたテストを書いてみましょう。
下記のシナリオとします。

* シナリオ名: nginxの初期表示画面から公式ページに飛ぶことができる
* 前提条件: nginxの初期表示画面が表示されている
* アクション: nginx.com のリンクをクリックする
* 結果: 遷移したページに Welcome to NGINX! が表示されている。

# 実装
## featureファイル
まずは、`features`ディレクトリを作り、`nginx.feature`というファイルを作成します。
このファイルにはエンドユーザーが辿るシナリオを書きます。

```nginx.feature
Feature: nginx画面
  エンドユーザーがnginxの様々なページで動作を行うシナリオ

  Scenario: nginxの初期画面から公式ページに飛ぶことができる
    Given nginxの初期画面が表示されている
    When "nginx.com" のリンクをクリックする
    Then 遷移したページに "Welcome to NGINX!" が表示されている
```

cucumber.jsの実行時には、featuresディレクトリが自動的に読み込まれ、その中の〇〇.featureファイルが自動実行される仕組みになっています。

この状態で実行すると、下記のようにコンソール上に「シナリオに対する実装の定義部分がないですよ」と怒られます。

```
$ ./node_modules/.bin/cucumber-js
UUU

Warnings:

1) Scenario: nginxの初期画面から公式ページに飛ぶことができる # features/nginx.feature:4
   ? Given nginxの初期画面が表示されている
       Undefined. Implement with the following snippet:

         Given('nginxの初期画面が表示されている', function () {
           // Write code here that turns the phrase above into concrete actions
           return 'pending';
         });

   ? When "nginx.com" のリンクをクリックする
       Undefined. Implement with the following snippet:

         When('{string} のリンクをクリックする', function (string) {
           // Write code here that turns the phrase above into concrete actions
           return 'pending';
         });

   ? Then 遷移したページに "Welcome to NGINX!" が表示されている
       Undefined. Implement with the following snippet:

         Then('遷移したページに {string} が表示されている', function (string) {
           // Write code here that turns the phrase above into concrete actions
           return 'pending';
         });


1 scenario (1 undefined)
3 steps (3 undefined)
0m00.000s
error Command failed with exit code 1.
```

ここに表示されたsnippetをアクション実装用のファイルにコピペしていけばOKです。

## アクション実装
`step_definitions`というディレクトリを作り、その中に`nginx_steps.js`というファイルを作ります。
ここに、先ほど表示されたsnippetを貼り付けていきます。
GivenなどのメソッドのImportも忘れずに追加しましょう。

```step_definitions/nginx_steps.js
const { Given, When, Then } = require("cucumber");

Given('nginxの初期画面が表示されている', function () {
  // Write code here that turns the phrase above into concrete actions
  return 'pending';
});

When('{string} のリンクをクリックする', function (string) {
  // Write code here that turns the phrase above into concrete actions
  return 'pending';
});

Then('遷移したページに {string} が表示されている', function (string) {
  // Write code here that turns the phrase above into concrete actions
  return 'pending';
});
```
これでOKです。
実行すると先ほどとは異なり、Givenの時点でPendingが返されテストの進行が止まります。

```
$ yarn run e2e_test
yarn run v1.22.0
$ ./node_modules/.bin/cucumber-js
P--

Warnings:

1) Scenario: nginxの初期画面から公式ページに飛ぶことができる # features/nginx.feature:4
   ? Given nginxの初期画面が表示されている # features/step_definitions/nginx_steps.js:3
       Pending
   - When "nginx.com" のリンクをクリックする # features/step_definitions/nginx_steps.js:8
   - Then 遷移したページに "Welcome to NGINX!" が表示されている # features/step_definitions/nginx_steps.js:13

1 scenario (1 pending)
3 steps (1 pending, 2 skipped)
0m00.000s
error Command failed with exit code 1.
```

ちなみにcucumberのシナリオ上で `遷移したページに "Welcome to NGINX!" が表示されている` のように`""` で括って記述した部分は、シナリオ実行時に引数としてメソッドに渡されます。
そのため、`step_definitions/nginx_steps.js` のWhenとThenには対応する部分が `{string}` として引数で定義されています。

## puppeteer実装
### Given
まずは、nginx初期表示です。
とりあえずpuppeteerで画面を表示するところまで書いてみます。

```step_definitions/nginx_steps.js
const { Given, When, Then } = require("cucumber");
const puppeteer = require('puppeteer');

var page = null;

Given('nginxの初期画面が表示されている', async function () { // awaitを使うのでasyncを追加。
  const browser = await puppeteer.launch({headless: false}); // ブラウザを表示するのでheadlessをfalseに
  page = await browser.newPage();
  await page.goto('http://localhost:8080'); // http://localhost:8080に遷移
});

// 以下省略
```
この状態で `$ yarn run e2e_test` をすると、下記のように自動でブラウザが立ち上がりnginx初期画面が表示されます。
<img width="988" alt="スクリーンショット 2020-02-29 0.54.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154121/58504516-1755-4480-ba47-ab7cb85d0bff.png">

しかし、この状態ではGivenの中で「表示されているのがnginxの初期画面であること」は保証できていません。
そのため画面に表示された要素に対してアサーションをかけます。

```step_definitions/nginx_steps.js
const { Given, When, Then } = require("cucumber");
const puppeteer = require('puppeteer');
const { assert } = require('chai');

var page = null;

Given('nginxの初期画面が表示されている', async function () {
  const browser = await puppeteer.launch({headless: false});
  page = await browser.newPage();
  await page.goto('http://localhost:8080');

  // h1タグ内の文字列を取得
  const h1Text = await page.$eval('h1', item => {
    return item.textContent;
  }); 

  assert.equal(h1Text, 'Welcome to nginx!');  // h1タグ内の文字列が「Welcome to nginx!」であるかのアサーション
});


// 以下省略
```
これでGiven内のチェックの実装ができました。
実行すると下記のように、Givenのテストはパスし、WhenでPendingとなります。

```
$ yarn run e2e_test
yarn run v1.22.0
$ ./node_modules/.bin/cucumber-js
.P-

Warnings:

1) Scenario: nginxの初期画面から公式ページに飛ぶことができる # features/nginx.feature:4
   ✔ Given nginxの初期画面が表示されている # features/step_definitions/nginx_steps.js:5
   ? When "nginx.com" のリンクをクリックする # features/step_definitions/nginx_steps.js:18
       Pending
   - Then 遷移したページに "Welcome to NGINX!" が表示されている # features/step_definitions/nginx_steps.js:23

1 scenario (1 pending)
3 steps (1 pending, 1 skipped, 1 passed)
0m01.351s
```

### When
「nginx.com」をクリックします。

```step_definitions/nginx_steps.js
// Givenは省略

When('{string} のリンクをクリックする', async function (linkText) {
    const linkXpath = `//a[text() = "${linkText}"]`; // nginx.comリンクのxpathを取得
    await page.waitForXPath(linkXpath);
    await (await page.$x(linkXpath))[0].click(); // リンクをクリック
});

// 以下両略
```

実行すると、下記のようにnginx公式ページまで遷移できることが確認できました。
<img width="1005" alt="スクリーンショット 2020-02-29 1.14.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154121/656d6c2c-8468-dc10-407c-5c4fa73471a5.png">

なお、指定したテキストを持つリンクをクリックする方法は下記を参考に実装しております。
[PuppeteerでinnerTextを使って要素を選択する](https://qiita.com/shora_kujira16/items/34cb4074dfa715007698)

### Then
最後です。
公式ページに遷移するリンクは、Whenの`click()`メソッドで動作が保証できているので、遷移先のページに「Welcome to NGINX!」が表示されているかをチェックしましょう。
（ちなみに初期画面のnginxは小文字ですが、公式ページのNGINXは大文字です）

```step_definitions/nginx_steps.js
// 省略

Then('遷移したページに {string} が表示されている', async function (pageTitleText) {
    await page.waitForNavigation({ waitUntil: "domcontentloaded" }); // ページのローディングが終わるまで待つ

    // h1タグ内の文字列を取得
    const h1Text = await page.$eval('h1', item => {
        return item.textContent;
    }); 

  assert.equal(h1Text, pageTitleText);  // h1タグ内の文字列が「Welcome to NGINX!」であるかのアサーション
});
```

この状態で実行すると、全ての条件実行とアサーションまで完了し、テストが完了している出力がされます。

```
$ yarn run e2e_test
yarn run v1.22.0
$ ./node_modules/.bin/cucumber-js
...

1 scenario (1 passed)
3 steps (3 passed)
0m04.815s
```

以上で実装と確認は終了です！

## 完成したファイル
### features/nginx.feature

```feature/nginx.feature
Feature: nginx画面
  エンドユーザーがnginxの様々なページで動作を行うシナリオ

  Scenario: nginxの初期画面から公式ページに飛ぶことができる
    Given nginxの初期画面が表示されている
    When "nginx.com" のリンクをクリックする
    Then 遷移したページに "Welcome to NGINX!" が表示されている
```

### step_definitions/nginx_steps.js

```step_definitions/nginx_steps.js
const { Given, When, Then } = require("cucumber");
const puppeteer = require('puppeteer');
const { assert } = require('chai');

var page = null;

Given('nginxの初期表示画面が表示されている', async function () {
  const browser = await puppeteer.launch({headless: false});
  page = await browser.newPage();
  await page.goto('http://localhost:8080');

  // h1タグ内の文字列を取得
  const h1Text = await page.$eval('h1', item => {
    return item.textContent;
  }); 

  assert.equal(h1Text, 'Welcome to nginx!');  // h1タグ内の文字列が「Welcome to nginx!」であるかのアサーション
});

When('{string} のリンクをクリックする', async function (linkText) {
    const linkXpath = `//a[text() = "${linkText}"]`; // nginx.comリンクのxpathを取得
    await page.waitForXPath(linkXpath);
    await (await page.$x(linkXpath))[0].click(); // リンクをクリック
});

Then('遷移したページに {string} が表示されている', async function (pageTitleText) {
    await page.waitForNavigation({ waitUntil: "domcontentloaded" }); // ページのローディングが終わるまで待つ

    // h1タグ内の文字列を取得
    const h1Text = await page.$eval('h1', item => {
        return item.textContent;
    }); 

  assert.equal(h1Text, pageTitleText);  // h1タグ内の文字列が「Welcome to NGINX!」であるかのアサーション
});
```

# 最後に
BDDフレームワークを使えば振る舞いに対してメソッドを自動出力し、わかりやすい形ながらも工数をあまりかけずに実装することができます。
さらに、BDDフレームワークを使用してテストを書いた場合、featureファイル「エンドユーザーがどんなことをするサービスか」を表すドキュメントになるので、チーム開発においてプロダクトのコア機能の認識も取りやすくなるメリットがあります。

是非参考にしてみてください。