虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Puppeteerを使ってWebサイトの自動テストツールを作ってみた

こんにちは。虎の穴ラボのH.Hです。

今回はWebサービスの自動テストを試作したので、作ったアプリケーションについて書こうと思います。

なぜ作ろうと思ったか

虎の穴ラボでは、とらのあな通販やFantiaなど多くのWebサービスの開発を行っています。
日々様々な機能の追加や変更が行われています。
変更した機能は関連する既存機能を含めてテストを行い、本番の環境にリリースしていきます。
既存部分のテストに関して、簡略化できないかと考え自動でテストを行うツールを作ろうと思いました。

そもそもWebサービスのテストはなぜ簡略化が必要か

テストは機能追加をしていくと、次に挙げる問題のためにテストにかける時間が増えていきます。

【問題1】機能追加を行う毎に確認項目が増える

何かの検索を行うサービスを考えると、1つの検索機能は様々な条件を元に該当するデータを取得します。
何か変更があれば既存の検索結果に影響がないかを確認する必要が出てくるため、変更が加わる毎に確認する必要のある項目が増えます。

【問題2】「レスポンスが返ってくること」だけで正常と判断できない

検索が動いても条件通りの検索結果が出てこなければ意味がなく、実際に実行して人が表示内容を確認する必要がある場合があります。

【問題3】共通ロジックへの変更は、影響範囲が膨大

ロジックの共通化は当然どのようなアプリケーションでも行われていると思います。
いろいろな場所で使っている共通ロジックの修正を行うと、影響する動作の確認が必要になります。

【問題4】特殊な場合の動きを忘れがち

条件1と条件2が重なった場合には特殊な動きをするようなテストは、テストケースから漏れやすい点です。
そのためのテストケースのチェックにかける時間が、サービスの拡大に伴い大きくなります。



そこで今回は上記の問題を解決するためのアプリケーションを試作します。

仕様

・テスト対象はブラウザで動作するWebサービス
・レスポンス内容のチェックを行う(レスポンスコードだけではなく、特定の文字列が入っている事もしくは入っていない事の確認する)
・環境さえ作れば誰でもテストを実施でき、テストを容易に追加できるようにする
・画面の操作とは別に画面のスクリーンショットも撮れるようにする
・ログイン情報は設定ファイルに残したくないので、起動時のパラメータにする

今回はPuppeteerを利用して作りたいと思います。
Puppeteerの詳細はこちらのGitHubに記載があります。

github.com

Puppeteerは実際にブラウザを表示させずにWebサイトへアクセスしたり、画面上のボタンをクリックするなどの操作を行うことができるツールです。
ChromiumのDevToolsの機能を使用しているので、アクセス時間やデータ転送量なども取得可能です。
今回はアクセス時間やデータ転送量までは取得しません。アクセス時間やデータ転送量など取得方法について「虎の穴ラボの薄い本。vol.3」に書かれているので、ぜひご覧ください。

ecs.toranoana.jp

環境構築&アプリケーション作成

アプリケーション作成に使用した環境は以下の通りです。

OSmac
Node.jsv14.4.0
npm6.14.5
Puppeteer4.0.0

※PuppeteerにNode.jsが必要になります。

1 Node.jsのインストール

Puppeteerを動かすために必要なNode.jsをインストールを行います。 インストールにはHomebrewやnodebrewを使用します。

コマンドは以下の通りです。

//Node.jsのバージョン管理ツール
$brew install nodebrew

//nodebrewにパスを通す
$echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile

//最新版のNode.jsをいれる(入れるだけでは最新のNode.jsが使用されることにならないので注意)
$nodebrew install-binary latest

//最新版のNode.jsのバージョンを確認
$nodebrew ls
//出力例
v14.4.0
current: none

//最新版のNode.jsを使用するように変更
$nodebrew use v14.4.0

//確認
$nodebrew ls

v14.4.0
current: v14.4.0

2 Puppeteerのインストール

今回作成するアプリケーション用のファイルを作成するディレクトリを作成し以下のコマンドを実行します。

$npm init
$npm install --save puppeteer

3 アプリケーションの作成

今回は以下の構成で作成します。

アプリケーションを入れるディレクトリ
  |--setting
  |    |--設定ファイル1
  |    `--設定ファイル2
  |--action.js
  `--function.js

setting配下に操作させる内容を記述したテキストファイルを配置して、テキストファイルを読み込むことで動作させます。設定ファイルの記述方法については後述します。
action.js・・・作成したツールの本体となるJavaScriptファイルで起動時に指定するファイル
faunction.js・・・Puppeteerを動かす関数を集めたJavaScriptファイル

Puppeteerはログの出力などは行わないので、処理状況についてはconsole.logでコンソール上に出力しています。

3.1 action.jsについて

action.jsは起動後に設定ファイルを読み取り、Webサイトへの移動やテキストを入力してボタンを押すなどのアクションを行っていきます。
設定ファイルについては4に記述します。

action.js

const fs = require("fs");
const readline = require("readline");

(async() => {
    require('events').EventEmitter.defaultMaxListeners = 0;
    const f = require('./function.js');

    let browser = await f.browserInit();
    let page = await f.pageInit(browser);

    let fileList = [];
    await getFileList(fileList);

    //設定読み込み
    let user = process.argv[2];
    let password = process.argv[3];
    
    let returnCode = 0;
    let errorList = [];
    for (let fileindex = 0; fileindex < fileList.length; fileindex++) {

        let commandList = [];
        await inputfile('./setting/' + fileList[fileindex], commandList);
        console.log('実行コマンド一覧');
        console.log(commandList);
        browser = await f.browserInit();
        await f.pageInit(browser);
        for (let index = 0; index < commandList.length; index++) {
            const params = commandList[index].split(',');
            try {
                if (params[0] == 'move') {
                    console.log(params[1]);
                    let response = await f.move(page, params[1]);
                    if (params[2] != undefined && params[2] != '' && response._status != params[2]) {
                        returnCode = 1;
                        errorList.push('STATUSCODE_ERROR:' + fileList[fileindex] + ' :' + (index +1) + '行目:想定コード=' + params[2] + ' レスポンスコード=' + response._status);
                    }
                    const html = await page.content();
                    if (params[3] != undefined && params[3] != '' && html.indexOf(params[3]) == -1) {
                        returnCode = 1;
                        errorList.push('RESPONSE_ERROR:' + fileList[fileindex] + ' :' + (index + 1) + '行目:含まれているべき文字列=' + params[3]);
                    }
                    if (params[4] != undefined && params[4] != '' && html.indexOf(params[4]) != -1) {
                        returnCode = 1;
                        errorList.push('RESPONSE_ERROR:' + fileList[fileindex] + ' :' + (index + 1) + '行目:存在してはいけない文字列=' + params[4]);
                    }
                } else if (params[0] === 'screenshot') {
                    await f.fullScreenShot(page, params[1]);
                } else if (params[0] === 'click') {
                    await f.click(page, params[1], params[2]);
                } else if (params[0] === 'input') {
                    await f.input(page, params[1], params[2]);
                } else if (params[0] === 'login') {
                    await f.input(page, params[1], user);
                    await f.input(page, params[2], password);
                    await f.click(page, params[3], params[4]);
                } 
            } catch (errr) {
                console.log(errr);
                errorList.push('CRITICAL_ERROR:' + fileList[fileindex] + ' :' + (index + 1) + '行目 処理できないエラーが発生しました');
                returnCode = 1;
            }
        }

        await browser.close();
    }
    await browser.close();
    
    if (returnCode != 0) {
        //エラーが検出された際の処理
        console.log(errorList);
    }else{
        //全て正常に動作した場合の処理
    }

    process.exit(returnCode)
})();

const inputfile = async(file, commandList) => {
    const stream = fs.createReadStream(file);
    const rl = readline.createInterface({
        input: stream
    });
    for await (const line of rl) {
        commandList.push(line);
    }
};

const getFileList = async(fileList) => {
    files = fs.readdirSync('./setting');
    files.forEach(function(file) {
        fileList.push(file);
        console.log(file);
    });
};

3.2 function.jsについて

Puppeteerに関する関数をまとめたファイルになります。
function.js

const puppeteer = require('puppeteer');
const assert = require('assert');

module.exports.browserInit = async function() {
    const puppeteer = require('puppeteer');
    const assert = require('assert');
    const viewportHeight = 1200
    const viewportWidth = 1600
    const browser = await puppeteer.launch({
        headless: true
    });
    return browser;
};

module.exports.pageInit = async function(browser) {
    const page = await browser.newPage();
    return page;
};

module.exports.fullScreenShot = async function(page, filename) {
    await page.screenshot({
        path: filename,
        fullPage: true
    });
    console.log('fullScreenShot end');
};

module.exports.move = async function(page, url) {
    respose = await page.goto(url);
    console.log('move end');
    return respose;
};

module.exports.click = async function(page, name, type) {
    if (type == 'id') {
        const response = await Promise.all([  page.waitForNavigation(),   page.click('#' + name + ''), ]);
        return response[0];
    } else if (type == 'input_submit_name') {
        const response = await Promise.all([  page.waitForNavigation(),   page.click('input[name="' + name + '"]'), ]);
        return response[0];
    } else if (type == 'class') {
        const response = await Promise.all([  page.waitForNavigation(),   page.click('.' + name + ''), ]);
    } else if (type == 'class_no_wait') {
        const response = await Promise.all([  page.click('.' + name + ''), ]);
    } else if (type == 'id_no_wait') {
        const response = await Promise.all([  page.click('#' + name + ''), ]);
        return response[0];
    } else if (type == 'input_submit_name_no_wait') {
        const response = await Promise.all([  page.click('input[name="' + name +'"]'), ]);
        return response[0];
    }
};

module.exports.input = async function(page, name, param) {
    await page.type('input[name="' + name + '"]', param);
};

4 設定ファイルについて

設定ファイルは、以下の定義で記述します。

・操作を特定するコードと必要なパラメータをカンマ区切りで記述する
・1行毎に個別の操作とする

今回作成した物は以下の操作に対応しています。

4.1 アドレスバーにURLを入れて画面遷移させる

記述方法)

move,遷移先のURL,ステータスコード,特定の文字列が含まれていることのチェック対象の文字列,特定の文字列が含まれていないことのチェック対象の文字列


例)GitHubのトップページに正しく遷移する(レスポンスコードが200で、GitHubという文言が含まれ、テストという文言は含まれない)

move,https://github.com/,200,GitHub,テスト

4.2 画面全体のスクリーンショットを取得し画像ファイルとして保存する

記述方法)

screenshot,ファイル名

例)toppage.pngというファイルに出力する

screenshot,toppage.png

4.3 画面上のボタンを押す

ボタンを押す動作に関しては記述方法によっていくつかの種類があるため少し特殊です。
記述方法)

click,ボタンを特定するための要素の値,ボタンのHTML記述方法を特定する値

ボタンについては以下のパターンに対応しました。
・id属性が指定されているボタン
・name属性が指定されているボタン
・class属性が指定されているボタン

またそれぞれに対して画面遷移を待つか待たないかの場合分けも行っています。
(画面遷移を待たない処理はポップアップなどを閉じるような場合に使用します)

ボタンのHTML記述方法を特定する値の対応表は以下の通りです。

画面遷移を待つ画面遷移を待たない
id属性指定idid_no_wait
name属性指定input_submit_nameinput_submit_name_no_wait
class属性指定classclass_no_wait

例)idがsubmitのボタンを押す場合

click.submit,id


4.4 画面上の入力欄に値を設定する

name属性の値を指定して入力欄に値を設定します。 記述方法)

input,name属性の値,実際に入力する値


例)passwordという属性の入力欄にtestpasswordを入れる

input,password,testpassword


4.5 IDとパスワードを入力してログイン処理を行う

4.3と4.4を組み合わせたログイン処理を明示的に行います。
ログインするためのIDとパスワードの起動時に指定します。
記述方法)

login,IDを入れる入力枠のname属性の値,パスワードを入れる入力枠のname属性の値,ログインする時におすボタンの要素の値,ボタンのHTML記述方法を特定する値


例) ログインをする際にIDをidというname属性に、パスワードをpasswordというname属性に、そしてidがbutton_idというボタンを押してログインする

login,id,password,button_id,id


実行

今回作成したアプリケーションは以下のコマンドで起動します。

$node action.js ログインID パスワード

試しにGithubへのログインを試してみます。
設定ファイルはgithub_loginという名前にしています
github_login

move,https://github.com/,200,,
screenshot,toppage_screen.png
move,https://github.com/login,200,,
screenshot,login_page.png
login,login,password,commit,input_submit_name
screenshot,login_toppage_screen.png
move,https://github.com/logout
click,btn,class
screenshot,logout_toppage_screen.png

正常に動いた場合の出力例

github_login
実行コマンド一覧
[
  'move,https://github.com/,200,,',
  'screenshot,toppage_screen.png',
  'move,https://github.com/login,200,,',
  'screenshot,login_page.png',
  'login,login,password,commit,input_submit_name',
  'screenshot,login_toppage_screen.png',
  'move,https://github.com/logout',
  'click,btn,class',
  'screenshot,logout_toppage_screen.png'
]
https://github.com/
move end
fullScreenShot end
https://github.com/login
move end
fullScreenShot end
fullScreenShot end
https://github.com/logout
move end
fullScreenShot end

正常に終わった場合の出力は設定ファイル名の一覧→実際に実行される操作→動いている際のログ(画面遷移などどこまで進んでいるかを確認するため)となります。

仮にhttps://github.com/のレスポンスコードのチェックを200から404に変更すると以下のような出力になります。

実行コマンド一覧
[
  'move,https://github.com/,404,,',
  'screenshot,toppage_screen.png',
  'move,https://github.com/login,200,,',
  'screenshot,login_page.png',
  'login,login,password,commit,input_submit_name',
  'screenshot,login_toppage_screen.png',
  'move,https://github.com/logout',
  'click,btn,class',
  'screenshot,logout_toppage_screen.png'
]
https://github.com/
move end
fullScreenShot end
https://github.com/login
move end
fullScreenShot end
fullScreenShot end
https://github.com/logout
move end
fullScreenShot end
[ 'STATUSCODE_ERROR:github_login :1行目:想定コード=404 レスポンスコード=200' ]

以下のコードの部分で正常な場合と以上な場合で分けているので、メールやSlackなどへの通知を行いたい場合はこの部分に記述することで個別の通知を行うことが可能です。

     if(returnCode != 0){ 
         //エラーが検出された際の処理
         console.log(errorList);
     }else{
         //全て正常に動作した場合の処理
     }

作成時に気がついた点

作成時に気がついた点がいくつかあるのでこちらでまとめます。

MaxListenersExceededWarningへの対策が必要

Puppeteerを利用するサンプルコードを載せているいくつかのサイトを参考に今回のコードを作成しましたが、当初10回以上の画面遷移やスクリンショットなどの操作を行うと警告が出力されました。
参考にしたサイトでは、10回以上の操作がなかったので警告が発生しなかったと思われます。
メモリーリークへの警告になりますが、今回は操作を行う数は10では足りないので制限を外す対応を行いました。

該当のコード

require('events').EventEmitter.defaultMaxListeners = 0;

数値を指定することで、警告を出す上限値を変更できます。0を指定すると上限はなくなります。

ボタンを押す動作が何よりも難しい

画面表示されているボタンを押すという動作ですが、bottunタグやinputタグなど記述方法が様々あり、どのボタンを押すのかを決めるための場合わけを準備する必要が出てきました。
また、画面遷移を伴うボタンや画面上で表示を閉じるためだけのボタンで対応を分ける必要があることも実装中に気がつきました。
(画面遷移の完了を待つにはwaitForNavigation()を設定する必要があります。ただし、ポップアップを閉じるボタンを押す時に、waitForNavigation()を設定するとタイムアウト時間まで待ち、その後エラーになってしまいます。)

まとめ

今回Puppeteerを使い、Webサイトの表示や画面遷移を行い動作確認するツールを作りました。
Webサイトへの表示やボタンを押しての動作などブラウザで操作していることが一通りでき、自動テストができる道筋はできたかなと考えています。
徐々にとらのあな通販などの開発時にテストケースを追加していき、確認テストの時間を短くしていくことができれば良いなと考えています。
GitHub上の説明でFireFoxで動かすための機能が実験的にサポートされていることや、Microsoft Edgeの中身がChromiumベースになるなどの動きもあるので、主要なブラウザを同時にテストできるようになればもっとよくなるかなと思いました。

P.S

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。
カジュアル面談では虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今期何見ました?」といったオタクトークから業務の話まで何でもお応えします。

カジュアル面談や採用情報はこちらをご確認ください。
yumenosora.co.jp

また、毎週火曜、木曜にはTora-Lab Meetup!と称して虎の穴ラボのエンジニア・採用担当とお話できる機会を設けさせていただくことになりました。
虎の穴ラボに興味がある、エンジニアや採用担当に質問したいことがある、などどなたでもご参加下さい。
news.toranoana.jp

さらに、弊社では新型コロナウイルス感染症終息後もフルリモートを継続導入することになりました!
地方在住のまま働きたい人など、上記Meetupやカジュアル面談、面接すべてリモート対応していますので、ご興味のある方はぜひいずれか応募してみてください! prtimes.jp

イベント情報

7月10日には定例開催している会社説明会をオンライン開催します。 どなたでも参加できるので、とらラボがどんなところか聞いてみたいという人は是非参加してください。 yumenosora.connpass.com

また、7月17日には虎の穴ラボの社員がどのように働いているかを紹介する座談会も開催します。
3月から始まったリモートワークに焦点を当てて、社員が語り合うイベントとなります。
是非こちらもご参加ください。 yumenosora.connpass.com