puppeteerで自作Slack BotのE2Eテストを自動化した


# やったこと
Slack Botを自作して使っている。
GmailのチェックだったりGoogle Calendarの予定を確認できる他、色々なお遊び機能を搭載したBotなのだがエンハンスを重ねるにつれて徐々に機能が増えてきた。
アップデートのたびに全機能を手動でテストするのがしんどくなったので、puppeteerを使ってE2Eテストを自動化してみた。

要はSlackのWebクライアントををpuppeteerで操作したという話なので、puppeteerに興味があるの人の参考にはなるかもしれない。
TypeScriptで書いている。

## テストしたい機能
![https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_601155_35718126-5106-8d6d-9229-115fe86eb244.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/f6e06ebe-7ac1-9b2a-e002-59c90a6c26d4.gif)

ユーザーからのメンションに対して応答をくれるbotなのだが、お遊びで機能を追加したところこんなにパターンがある。

- Google Calendar系　6種類
- Gmail系 1種類
- お勉強系 1種類
- 抽選系 3種類
- お話系 1種類

命令だけで合計12種類。他にもこんな機能がある。

- Botがいるチャンネルにユーザーが参加した時にWelcomeメッセージを送る
- 許可ユーザー以外から上記の命令された時に拒否する
- 許可したチャンネル以外に招待されメンションで話しかけられた時に冷たくあしらう

全機能をテストするには１２種類の命令に加えて、特定のチャンネルにユーザーを追加したり許可がないユーザーでログインしてbotに話しかけたりする必要がある。
手作業ではかなりの手間になる。

## 自動化できたこととできなかったこと
### 自動化できたこと

- 上記の命令形コマンド全てをSlack上でbotに対して実行する
- 複数のユーザーアカウントを使ったテスト（許可ユーザーと非許可ユーザー）
- チャンネルの入退場に関するテスト
- テスト自体の実行 (masterへのプルリクをトリガーにCI環境で自動実行される）

### 自動化できなかったこと
- Botが正しい返答をしたことを確認する（アサーションする）

自動化にアサーションを含めることは現時点ではできていないので、botが正しい返答をしたかどうかは結局目で確認する必要がある。
それでも、テストの実行完了後にSlackのログを確認するだけで良いのでずいぶん手間は軽減されている。

## テストの流れ
1. Slack Botを起動する
2. puppeteerを起動する
3. puppeteerでSlack Webのテスト用ワークスペースにテスト用アカウントでログインする
4. botの各機能をテストする

要は、手でSlackに命令を打ち込んだりしてテストしていた部分をpuppeteerにやってもらおうと言う話

# puppeteerでSlackを操作するコード解説
puppeteerの基礎についてはここでは割愛。[puppeteer/puppeteer][1]のReadmeを読めば使い方はわかる。
将来的にはアサーションも追加したいとは思っているので、[just-puppeteer][2]でテストを起動している。

Slackにログインしてbotにメンション付きでメッセージを送信するところまでのコードを作成していく。
## 必要なモジュールのインストール
```
npm install jest jest-puppeteer puppeteer ts-jest typescript @types/jest @types/jest-environment-puppeteer --save-dev
```


## Slackにログインする
最初なので丁寧に解説
まずはSlackのログイン画面を確認して、必要な操作を確認する。
(ここでは2020/08/30時点でのSlackのUIで解説します）

### ワークスペース名の入力
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/8887ccf2-9771-8cc1-2978-34fd9a03bff2.png)

最初の画面の操作は以下

1. ワークスペース名をテキストボックスに入力する。
2. Continueボタンをクリックする。

テキストボックスとContinueボタンの２つのDOMを操作する必要があるのでセレクタを作成する。
Chrome開発者ツールでHTMLを確認し、使えそうな属性を拾い上げてセレクタを定数として宣言しておく。

```typescript:selectors.ts
export const loginSelectors = {
  CONTINUE_BUTTON_SELECTOR: '[data-qa=submit_team_domain_button]',
  WORKSPACE_INPUT_SELECTOR: '[data-qa=signin_domain_input]',
} as const
```

次にこのセレクタを使ってWeb画面を操作するコードを書く。
E2Eテスト全体のコードは長くなるので、loginの処理は関数に切り出して作成すると良いと思う。

```typescript:loginToSlack.ts
import { loginSelectors } from './selectors';
import { Page } from 'puppeteer';

export default async function loginToSlack(workSpaceName: string, page: Page) {
  await page.waitForSelector(loginSelectors.CONTINUE_BUTTON_SELECTOR); // continueボタンがロードされるまで待つ
  await page.type(loginSelectors.WORKSPACE_INPUT_SELECTOR, workSpaceName); // ワークスペース名をテキストボックスに入力
  await page.click(loginSelectors.CONTINUE_BUTTON_SELECTOR); // Continueボタンをクリック
}
```
### メールアドレスとパスワードを入力する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/31464005-9c40-0c63-eea0-b3727cc00613.png)
次はメールアドレスとパスワード。Sign inボタンも操作する。
Chrome開発者ツールで使えそうな属性を探してセレクタを追加

```typescript:selectors.ts
export const loginSelectors = {
  CONTINUE_BUTTON_SELECTOR: '[data-qa=submit_team_domain_button]',
  WORKSPACE_INPUT_SELECTOR: '[data-qa="signin_domain_input"]',
  EMAIL_INPUT_SELECTOR: '[data-qa="login_email"]', 
  PASSWORD_INPUT_SELECTOR: '[data-qa="login_password"]',
  SIGNIN_BUTTON_SELECTOR: '#signin_btn',
} as const
```

次に画面を操作するpuppeteerのコードを追加

```typescript:loginToSlack.ts
import { loginSelectors } from './selectors';
import { Page } from 'puppeteer';

export default async function loginToSlack(workSpaceName: string, id: string, pw: string, page: Page) {
  await page.waitForSelector(loginSelectors.CONTINUE_BUTTON_SELECTOR); // continueボタンがロードされるまで待つ
  await page.type(loginSelectors.WORKSPACE_INPUT_SELECTOR, workSpaceName); // ワークスペース名をテキストボックスに入力
  await page.click(loginSelectors.CONTINUE_BUTTON_SELECTOR); // Continueボタンをクリック

  await page.waitForSelector(loginSelectors.EMAIL_INPUT_SELECTOR);  // emailのテキストボックスのロードを待つ
  await page.type(loginSelectors.EMAIL_INPUT_SELECTOR, id); // emailの入力
  await page.type(loginSelectors.PASSWORD_INPUT_SELECTOR, pw); // passwordの入力
  await page.click(loginSelectors.SIGNIN_BUTTON_SELECTOR); // sign inボタンをクリック
  await page.waitFor(7000); // ログイン完了まで適当な時間待つ　時間は適時調整する
}
```
sign inボタンを押してからSlackのチャットUIが表示されるまで.waitFor(7000)で待たせているは美しくないとは思う。
チャットUIのロードが完了したことを検知する方法は色々試してみたが安定する方法が見つからなかったので結局こうしている。


### ここまでのテストを実行する
ログインまで正しくできることを確認したい。
E2Eテストのメイン処理を作成する

```typescript:testOnSlack.e2e.ts
import { Page } from 'puppeteer';
import loginToSlack from './loginToSlack';

let incognitoPage :Page;
describe('Slack Web クライアントを使用したE2Eテスト', () => {
  beforeAll(async () => {
    const context = await browser.createIncognitoBrowserContext(); // シークレットブラウザを作成
    incognitoPage = await context.newPage();
    await incognitoPage.setExtraHTTPHeaders({
      'Accept-Language': 'en-US', // 言語を固定
    });

    await incognitoPage.setViewport({ width: 800, height: 600 });
    await incognitoPage.goto('https://app.slack.com/signin?slack_workspace_login_url');
  })

  it('Slackログイン', async () => {
    await loginToSlack('WORKSPACE_NAME', 'ACCOUNT_ID', 'ACCOUNT_PW', incognitoPage);
  });
});
```
Cookieなどが悪さをしないためにシークレットブラウザを利用してテストをする。
今後複数windowでテストをするときはログイン情報がcookieに記録されてしまうのでシークレットが無難。
言語を固定するのは実行環境に左右されなくするため。手元は日本語、 CI環境は英語と言う違いはよくあるので固定した方が無難。

[jest-puppeteer][2]のconfigを以下のように作成した
headlessモードかどうかを環境変数で指定できるようにしているだけなので必須ではない。

```jest-puppeteer.config.js
module.exports = {
  launch: {
    headless: process.env.HEADLESS !== 'false',
  }
}
```
jestのconfigは以下

```javascript:jest.config.js
module.exports = {
  preset: "./presets.js",
  testTimeout: 40000,
  testRegex: '(e2e|spec)\\.(tsx|ts|js|jsx)$',
  moduleFileExtensions: [
    'ts',
    'tsx',
    'js',
    'jsx',
    'json',
    'node',
  ],
};
```

TypeScriptとjest-puppeteerのpresetを使用する

```javascript:presets.js
const jestPuppeteer = require("jest-puppeteer/jest-preset");
const tsJest = require("ts-jest/jest-preset");

module.exports = {
  ...jestPuppeteer,
  ...tsJest,
}
```
あとは以下のようなnpm scriptを作成して実行する。

```json
"test:puppeteer": "HEADLESS=false JEST_PUPPETEER_CONFIG=jest-puppeteer.config.js  npx jest -i --config=jest.config.js"
```
```
npm run test:puppeteer
```
問題なくできていればシークレットブラウザが立ち上がり、Slackへのログインを試みてくれるはず。


## Slack本体の操作
ログインが完了したらいよいよテストに入る。
基本的な流れはログイン画面の時と変わらず、操作したい箇所のセレクタを作成して操作するコードを書く。

Slackで最初のテストを行うまでの流れは以下

1. テスト用のチャンネルに表示を切り替える
2. メッセージボックスにテスト用のメッセージをタイプする
3. メッセージを送信する

### チャンネルの切り替え
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/977bb8fc-aa9b-e378-e8ee-eed89c7e7fde.png)
同じ流れなのでスピードアップする。
左サイドバーから対象のチャンネル名のボタンをクリックすれば良い。

```typescript:testOnSlack.e2e.ts
    await incognitoPage.click(workSpaceSelectors.SIDEBAR_TESTCHANNEL_SELECTOR) // セレクタ定数を追加すること
    await incognitoPage.waitFor(1000) // チャンネルが切り替わったことを安定して検知できなかったので1秒待機
```

### メッセージの送信
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601155/cffdf3d0-1972-9414-6dee-36e493e5df1c.png)
ここも同じ流れ。テキストボックスにメッセージを打ち込んで送信ボタンをクリックするコードを書けばいい。
テスト対象のbotに対してメッセージを送信する処理はテスト内で何度も行うので関数にしておくといい。

```typescript:typeMessageForBot.ts
import { Page } from 'puppeteer';
import { workSpaceSelectors } from './selectors';

export async function typeMessageForBot(message: string, messagePaneSelector: string, page: Page){
    await page.type(messagePaneSelector, `@botname ${message}`); // botにメンションをつけたメッセージをタイプ
    await page.click(workSpaceSelectors.SEND_BUTTON_SELECTOR); // 送信ボタン押下
    await page.waitFor(2000); // botが応答するまで適当な時間待たせる
}
```

### テスト本体のコード
ここまで作成した関数などを組み合わせて、Slackログイン→botに対してメッセージを送信するまでのコードは以下のようになる。

```typescript:testOnSlack.e2e.ts
import { Page } from 'puppeteer';
import loginToSlack from './loginToSlack';
import { workSpaceSelectors } from './selectors';
import { typeMessageForBot } from './typeMessageForBot';

let incognitoPage :Page;
describe('Slack Web クライアントを使用したE2Eテスト', () => {
  beforeAll(async () => {
    const context = await browser.createIncognitoBrowserContext(); // シークレットブラウザを作成
    incognitoPage = await context.newPage();
    await incognitoPage.setExtraHTTPHeaders({
      'Accept-Language': 'en-US', // 言語を固定
    });

    await incognitoPage.setViewport({ width: 800, height: 600 });
    await incognitoPage.goto('https://app.slack.com/signin?slack_workspace_login_url');
  })

  it('Slackログイン', async () => {
    await loginToSlack('WORKSPACE_NAME', 'ACCOUNT_ID', 'ACCOUNT_PW', incognitoPage);
  });

  it('テスト用チャンネルに切り替え', async () => {
    await incognitoPage.click(workSpaceSelectors.SIDEBAR_TESTCHANNEL_SELECTOR),
    await incognitoPage.waitFor(1000)
  });

  it('botにメッセージを送信', async () => {
    await typeMessageForBot('テスト用メッセージ', workSpaceSelectors.MESSAGE_PANE_SELECTOR, incognitoPage);
  });
});
```
あとはひたすらコマンドをtypeMessageForBot()で打ち込み続ければいい。

この記事を見た皆さんが試してみる日にまだ使えるかはわからないが、このコードで使用したセレクタは以下
その時々のSlackのUIによって随時書き換えて欲しい。

```typescript:selectors.ts
export const loginSelectors = {
  CONTINUE_BUTTON_SELECTOR: '[data-qa=submit_team_domain_button]',
  WORKSPACE_INPUT_SELECTOR: '[data-qa="signin_domain_input"]',
  EMAIL_INPUT_SELECTOR: '[data-qa="login_email"]', 
  PASSWORD_INPUT_SELECTOR: '[data-qa="login_password"]',
  SIGNIN_BUTTON_SELECTOR: '#signin_btn',
} as const

export const workSpaceSelectors = {
  SIDEBAR_TESTCHANNEL_SELECTOR: '[data-qa="channel_sidebar_name_bots_debug"]', // チャンネル名の応じて変わるはず
  MESSAGE_PANE_SELECTOR: '.p-message_pane_input',
  SEND_BUTTON_SELECTOR: '[aria-label="メッセージを送信する"]',
} as const
```
ここまでのコードでnpm run test:puppeteerを実行すればメッセージの送信までのシナリオが実行できるはず。

## puppeteerの実行時にbotを起動する
上で作成したtest:puppeteerのnpm scriptはpuppeteerしか起動しないので、テスト対象のbotの起動は別で行う必要がある。
できればE2Eテスト開始前にbotが自動で起動されるようにしたい。

jest-puppeteerにはjest-dev-serverが組み込まれているのでjest-puppeteer.config.jsに定義を追加することで、テスト開始前に任意のコマンドを実行できる。

```javascript:jest-puppeteer.config.js
module.exports = {
  launch: {
    headless: process.env.HEADLESS !== 'false',
  },
  server: {
    command: 'NODE_ENV="production" npm run start', // botの起動コマンド
  }
}
```
これでnpm run test:puppeteerを実行するだけでbotを起動→E2Eテストを開始できる。

## 終わりに
今回はログイン〜メッセージの送信までをpuppeteerで実行する方法を紹介した。
ここで紹介したコードは2020/08/31時点でのSlackのUIを使用しているので、UIが変更されるとこのコードのままでは動作しなくなる。

E2Eテストを作成する流れは変わらないはずなので、変更になったUIの部分の新しいセレクタを作成してシナリオを組めばいい。
継続的にメンテナンスが必要とはいえ、手作業でbotにメッセージを打ち込んでいくよりはずっと効率的にテストできるので手間に見合うリターンは見込めると思う。

[1]:https://github.com/puppeteer/puppeteer
[2]:https://github.com/smooth-code/jest-puppeteer