Developer's Blog

AWS Lambda + Headless Chrome で E2E テスト

こんにちは、ウェブ共同開発部の西山です。

最近は AWS を触り倒す日々を送っています。
私が AWS の中でも特に多く触っているのが AWS Lambda (以下 Lambda) です。
Lambda はサーバーレスアーキテクチャを実現するサービスで、サーバーの管理をすることなく、コードの実装のみでサービスを実現できるのが大きなメリットです。

私はこの Lambda を E2E テストに利用しています。
E2E テストを Lambda で実装することにより、テストの定期実行設定から結果監視、結果の通知設定まで全て AWS 上で完結することができます。

今回はこの Lambda とヘッドレスブラウザを組み合わせた E2E テストの実装を紹介します。

Lambda + Headless Chrome で実装するメリット

E2E テストで必要と思われる機能はそれぞれ下記の AWS サービスで実現できます。

目的 利用する AWS サービス
E2E テスト実装 AWS Lambda
E2E テスト定期実行 AWS CloudWatch Events
E2E テスト結果監視 AWS CloudWatch Alarm
E2E テスト監視通知 AWS Simple Notification Service

このように E2E テストに必要な機能を AWS のサービスだけで実現することができます。

また今回利用するヘッドレスブラウザは Google Chrome (以下 Chrome) を使います。
Chrome を利用するメリットとして、盛んに開発が行われており、他のヘッドレスブラウザに比べて最新技術の対応が早いことが挙げられます。

ヘッドレスブラウザのビルド

Lambda で動作させる Chrome ですが、執筆時現在 ダウンロードページでは、Lambda で実行できるもの配布されていないため Chromium を使います。

Lambda で動作させる際の制限事項として、Lambda からは /tmp 以外のディレクトリにアクセスができません。
したがって Chromium がアクセスする /dev/shvm/tmp に変えてやる必要があります。

これらを踏まえて、Amazon Linux のイメージから EC2 インスタンスを作成し、その上で Lambda 実行用の Chromium をビルドしたいと思います。
手順は こちら を参考にしています。

必要なライブラリのインストール

EC2 インスタンスは、メモリ 16 GB 以上、ボリューム 30 GB 以上が推奨されているので、ケチらず c4.4xlarge を選択しました。
まず Lambda 環境に必要なライブラリをインストールします。

$ sudo yum install -y git redhat-lsb python bzip2 tar pkgconfig atk-devel alsa-lib-devel bison binutils brlapi-devel bluez-libs-devel bzip2-devel cairo-devel cups-devel dbus-devel dbus-glib-devel expat-devel fontconfig-devel freetype-devel gcc-c++ GConf2-devel glib2-devel glibc.i686 gperf glib2-devel gtk2-devel gtk3-devel java-1.*.0-openjdk-devel libatomic libcap-devel libffi-devel libgcc.i686 libgnome-keyring-devel libjpeg-devel libstdc++.i686 libX11-devel libXScrnSaver-devel libXtst-devel libxkbcommon-x11-devel ncurses-compat-libs nspr-devel nss-devel pam-devel pango-devel pciutils-devel pulseaudio-libs-devel zlib.i686 httpd mod_ssl php php-cli python-psutil wdiff --enablerepo=epel

Chromium のダウンロードと展開

Chromium をダウンロードしてきます。
10 分くらいかかりました。

$ cd ~
$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# Chromium ビルドツールにパスを通す
$ echo "export PATH=$PATH:$HOME/depot_tools" >> ~/.bash_profile
$ source ~/.bash_profile
# Chromium ビルド用ディレクトリ作成
$ mkdir Chromium
$ cd Chromium
# Chromium ダウンロード
$ fetch --no-history chromium

Chromium のビルド

アクセス先を /tmp に変更してビルドします。
このビルドにかなり時間がかかります。
c4.4xlarge の力でもってしても1時間くらいかかりました。

$ cd src
# /dev/shvm を使わないように設定
$ sed -i -e "s/use_dev_shm = true;/use_dev_shm = false;/g" base/files/file_util_posix.cc
$ mkdir -p out/Headless
# デバッグモードの無効化などの設定
$ echo 'import("//build/args/headless.gn")' > out/Headless/args.gn
$ echo 'is_debug = false' >> out/Headless/args.gn
$ echo 'symbol_level = 0' >> out/Headless/args.gn
$ echo 'is_component_build = false' >> out/Headless/args.gn
$ echo 'remove_webcore_debug_symbols = true' >> out/Headless/args.gn
$ echo 'enable_nacl = false' >> out/Headless/args.gn
$ gn gen out/Headless
# ビルド開始 (1時間くらいかかる)
$ ninja -C out/Headless headless_shell
# /tmp 移して圧縮
$ cp out/Headless/headless_shell /tmp/
$ cd /tmp
$ tar cfvz headless_shell.tar.gz headless_shell

Chromium のサイズ

圧縮後のファイルサイズを確認してください。

$ ls -lh /tmp/headless_shell.tar.gz

もしこれが 50 MB を超えると、Lambda の制限にひっかかり Lambda パッケージにこのファイルを含めることができなくなります。
解決策としては、S3 に配置し Lambda 実行時に動的に取得するという方法があります。
執筆時には 50 MB を超えてしまったため、S3 から取得する手順を記載します。

Lambda 関数の作成

Lambda にアップロードするパッケージを作っていきます。
今回は Node.js 6.10 向けに実装します。
今回実装する E2E テストは、対象の URL に対して撮影したキャプチャと、事前に用意した画像に違いがないかを確認するという簡易的なものにします。

利用する Node.js パッケージ

今回利用する Node.js パッケージは下記になります。

パッケージ名 用途
Puppeteer Chromium の操作
node-tar tar.gz の展開
image-diff 画像比較
Babel async/await の利用。Node.js 6.10 へのトランスパイル

日本語が表示できない問題の解決策

PhantomJS でも同じ問題が起きるのですが、Lambda には日本語フォントがインストールされていないため、日本語がうまく表示できません。
Lambda 環境には既に fontconfig があり、これを上書きしてフォントキャッシュを再作成することで日本語を表示させるという方法をとりたいと思います。
.font ディレクトリを作成し、 IPAフォント などの日本語フォントをダウンロードして配置します。

実装コード

ざっくりとした全体の流れは下記になります。

  1. 圧縮した Chromium を S3 から取得し、/tmp に展開する
  2. フォントキャッシュを作成する
  3. Chromium で対象 URL のキャプチャを保存する
  4. 事前に用意した画像とキャプチャを比較する

まず 1 のコードです。
Chromium を S3 から取得済みの場合はスキップします。

/**
* ヘッドレスブラウザの準備
*/
function setupChrome() {
   return new Promise((resolve, reject) => {
       // S3 から取得済みの場合スキップ
       if (existsFile(config.execPath)) {
           return resolve()
       }

       // S3 から取得
       s3.getObject({Bucket: config.bucket, Key: config.readKey}).createReadStream()
           .on('error', (err) => reject(err))
           // /tmp に展開
           .pipe(tar.x({C: config.tmpPath}))
           .on('error', (err) => reject(err))
           .on('end', () => resolve())
   })
}

/**
* ファイルの存在確認
* @param {string} path ファイルパス
* @returns {boolean}
*/
function existsFile(path) {
   try {
       fs.statSync(path)
       return true
   } catch(err) {
       return false
   }
}

次に 2 のコードです。
環境変数の HOMELMBDA_TASK_ROOT で上書きし、フォントキャッシュを再生成しています。

/**
* fontconfig 設定
*/
function setFontconfig() {
   return new Promise((resolve, reject) => {
       // ホームディレクトリパスを Lambda のタスクルートパスで上書き
       process.env.HOME = process.env.LAMBDA_TASK_ROOT
       // 日本語フォントが入ったディレクトリを指定して fontconfig 生成
       const command = `fc-cache -v ${process.env.HOME}.fonts`
       exec(command, (error, stdout, stderr) => {
           if (error) {
               return reject(error)
           }
           resolve()
       })
   })
}

次に 3 のコードです。
Puppeteer に Chronium 実行パスを指定し、キャプチャを撮ります。

/**
* キャプチャ取得
*/
async function capture() {
   // Puppeteer を設定
   const browser = await puppeteer.launch({
       headless: true,
       executablePath: config.execPath,
       args: [
           '--no-sandbox',
           '--disable-gpu',
           '--single-process',
       ],
   })
   const page = await browser.newPage()
   // キャプチャサイズを設定
   await page.setViewport(config.captureSize)
   // ページ遷移
   await page.goto(config.url)
   // キャプチャ取得
   await page.screenshot({path: config.captureImagePath})
   await browser.close()
}

最後に 4 のコードです。
image-diff で比較しますが、Chromium でキャプチャする画質がまちまちで、事前に用意した画像と完全一致しないことがありました。
そこで、差異が 1% 以内の場合は一致していると判定しています。

/**
* 画像比較
*/
function compare() {
   return new Promise((resolve, reject) => {
       const option = {
           actualImage: config.captureImagePath,
           expectedImage: config.expectImagePath,
       }
       // 事前に用意した画像とキャプチャ画像を比較
       imageDiff.getFullResult(option, (err, result) => {
           if (err) {
               reject(err)
           } else if (result.percentage > 0.01) {
               // 画像差異が 1% を超えた場合は異常とする
               reject(`Error image-diff: difference is over 1%`)
           } else {
               resolve()
           }
       })
   })
}

これらを通して実行します。

exports.handler = async (event, context, callback) => {
    try {
        await setupChrome()
        await setFontconfig()
        await capture()
        await compare()
        callback(null, 'Success')
    } catch (err) {
        callback(err, null)
    }
}

Lambda パッケージの作成

これを Babel で Node.js 6.10 向けにトランスパイルしてください。
トランスパイルしたコードと .font (日本語フォントが入ったディレクトリ)、 node_mudules を zip に圧縮すると Lambda パッケージの完成です。

なお、コードの途中に出てくる config は環境依存の設定を表します。
コードを利用する場合は、環境に合わせて適宜書き換えてください。

Lambda の設定

Lambda の設定でいくつか注意点があります。
Chromium がメモリを 350 MB ほど使うのでそれ以上に設定しましょう。
また S3 から圧縮した Chronium を取得するのに数秒かかるので、タイムアウトを 30 秒くらいは設定しましょう。

その他 AWS サービスの設定

AWS CloudWatch Events

実装した Lambda を定期実行させるため、AWS CloudWatch Events の設定を行います。
Lambda の設定画面から「トリガー」タブで「トリガーの追加」を選択します。
CloudWatch Logs を選択し、新しいルールを追加します。ルールタイプをスケジュール式にし、rate(1 day) と設定すると 毎日1回実行されます。cron 式も使えるので、好きな頻度に設定してください。

AWS CloudWatch Alarm

実装した Lambda の結果の監視設定を行います。
CloudWatch Alarm の新規作成から、今回実装した Lambda の Errors を選択します。
間隔を 5 分など短めの時間に、統計を「合計」にして Lambda にエラーが記録されるとすぐに通知されるようにしましょう。

AWS Simple Notification Service (SNS)

CloudWatch Alarm の監視結果の通知先を設定します。
AWS SNS の新しいトピックの作成し、サブスクリプションを追加します。
サブスクリプションをメール通知にしたり、Lambda にしてチャットサービスへ投稿したりできます。

これで設定は完了です。E2E テストの実行から通知まで、全て AWS のサービスで実現することができました。

まとめ

Lambda + Headless Chrome による E2E テストはいかがでしたか?

Lambda で実装することで、AWS の設定のみで E2E テストに必要な手順を賄うことができ、Headless Chrome を使うことで SPA など最新技術にも対応したテストを実装することができました。

Lambda も Headless Chrome もまだまだ成長が期待できるプロダクトで、様々な活用方法がでてくると思います。
まだ活用したことがない方はこの機会に是非活用してみてください!

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

フェンリルの開発者アカウントでは、フェンリルプロダクトの最新開発情報などをつぶやいています。よろしければフォローしてください!

フェンリルの Facebook ページでは、最新トピックをお知らせしています。よろしければいいね!してください!

Copyright © 2018 Fenrir Inc. All rights reserved.