[速習] Puppeteer ~ヘッドレスChromeでテスト&スクレイピング

第3章E2Eテストの実装 ~非同期処理のテスト、パフォーマンス計測、カバレッジ

第3章では、最も一般的なユースケースとして、Puppeteerを使ったE2Eend to endテストの実現方法を解説します。

なお、本章で扱うテストサイトには、ブラウザ自動化が必要となるSPASingle Page Applicationを使用します。しかしもちろん、SPAではないサイトでも同様にテストできます。

テスト対象の環境を構築する

まずはじめに、テスト対象となるサイトを構築する必要があります。今回は、Meteorを使った初期状態のサイトを、テスト対象として使用します。

Meteorとは、フロントエンドもバックエンドもすべてJavaScriptで記述できる、リアルタイム通信に特化したSPAのフレームワークです。現在最も人気のあるフレームワークではありませんが、今回は簡易的にSPAを構築できるという理由で選択しました。

テストサイトを立ち上げる

Meteorのインストール方法は、macOS/LinuxとWindowsでそれぞれ異なります。環境ごとのインストールコマンドは下記のとおりです。Windowsを使用する場合は、Chocolateyと呼ばれるインストーラが必要です。なお、Meteorのバージョンには1.8.0.2を使用します。

端末1 macOS/Linuxの場合
$ curl "https://install.meteor.com/?release=1.8.0.2" | sh
端末2 Windowsの場合
> choco install meteor&&meteor update --release 1.8.0.2

Meteorのインストールが完了したら、さっそく下記のコマンドを実行してテストサイトを立ち上げましょう。

端末3 テストサイトを作成
$ meteor create --full puppeteer-e2e-test
端末4 カレントディレクトリの移動
$ cd puppeteer-e2e-test
端末5 テストツールのアップデート
$ meteor add meteortesting:[email protected]_3
端末6 テストサイトを起動
$ meteor

テストサイトが起動したら、ブラウザのアドレスバーに http://localhost:3000 を入力してアクセスしてください。すると、図1のようなボタンのクリック回数のカウンタと、リンクの登録フォーム、およびリンク一覧だけのシンプルなサイトが表示されるはずです。

図1 初期状態のテストサイト
図1

カウンタ用のボタンをクリックしても、サーバへのリクエストは発生しません。その一方で、リンクの登録はサーバに送信され、データベースに保存されます。そのため、ブラウザをリロードするとクリック回数は0回にリセットされますが、ブラウザを変えても登録されたリンクは消えずに残ります。試しに、ボタンを1回クリックし、リンクを1件だけ登録してください。すると、図2のように画面の表示が切り替わるはずです。

図2 表示が切り替わったあとのテストサイト
図2

この画面が問題なく表示されていれば、テストサイトの構築は完了していています。CtrlCを押して、テストサイトのプロセスを終了させましょう。

テストの実行環境を整える

次に、テストの実行環境を整えます。Meteorはテストツールとして、MochaChaiをデフォルトで利用します。そのため、テストツールを追加でインストールする必要はありません。なお、一からテストツールを導入したい場合は、並列実行に強く、必要な機能がオールインワンでそろっているJestが便利でしょう。

Puppeteerはほかのテストツールとは完全に独立しているため、どのテストツールを選択してもまったく問題ありません。今回は、デフォルトのままMochaとChaiをテストツールとして使用します。

セットアップ

まずは、Puppeteerを下記のコマンドでインストールします。

端末7 Puppeteerをテスト用のパッケージにインストール
$ npm install --save-dev [email protected]

次に、Meteorが作成したpackage.jsonファイルを、下記のように直接書き換えてください。

リスト1 書き換え前
"test": "meteor test --once --driver-package meteortesting:mocha"
リスト2 書き換え後(macOS/Linuxの場合)
"test": "MOCHA_TIMEOUT=60000 TEST_CLIENT=0 meteor test --once --full-app --driver-package meteortesting:mocha"
リスト3 書き換え後(Windowsの場合)
"test": "set MOCHA_TIMEOUT=60000&&set TEST_CLIENT=0&&meteor test --once --full-app --driver-package meteortesting:mocha"

まずはじめに、MOCHA_TIMEOUT=60000の環境変数を渡すことで、Mochaのタイムアウトをデフォルトの2秒から10分に延長しました。この変更によって、デバッグモードでJavaScrptの実行を一時停止したときに、テストがすぐにタイムアウトで失敗することがなくなります。

次に、--full-appオプションを加えたことで、テストコマンドを実行したときに、Meteorのサーバサイドとクライアントサイドのアプリケーションが同時に立ち上がるようになりました。これによって、E2Eテストが実行できるようになります。また、TEST_CLIENT=0の環境変数を渡すことで、不要なテストが実行されないようにしました。

基本のテストコード

Meteorの仕様として、ファイル名が*.app-test.*と一致するスクリプトは、すべてサーバサイドからNode.js上で実行されます。そこで試しに、puppeteer-e2e-testディレクトリ直下に、e2e.app-test.jsというファイル名で下記のテストコードを保存してみましょう。

// アサーションを読み込む
const { expect } = require('chai');
// テストをグループ化する
describe('E2E', () => {
  // 実際のテストを書く
  it('should fail', () => {
    // アサーションを実行
    expect(true).to.equal(false);
  });
});

コード中のdescribe()it()は、Mochaによってグローバルスコープに定義された関数です。テストを階層構造でグループ化したり、テストの内容を記述するために使用されます。

一方、expect()はChaiの関数の一つで、下記のようにさまざまなアサーションをサポートしています。

// Aの値がBの値と一致する
expect(A).to.equal(B);
// Aの値がBの値と一致しない
expect(A).not.to.equal(B);
// AとBのオブジェクトが厳密に一致する
expect(A).to.deep.equal(B);
// Aの配列または文字列がBの値を含む
expect(A).to.include(B);
// Aの数値がBの数値より小さい
expect(A).to.be.below(B);
// Aの数値がBの数値より大きい
expect(A).to.be.above(B);

これらのアサーションに失敗すると、どのグループのどのテストが失敗したのかが、一目でわかりやすく表示されます。

テストの実行

先ほどのテストは、意図的にアサーションに失敗するコードになっていました。そこで下記コマンドを実行して、実際のテスト結果を確認しましょう。

端末7 テストを実行する
$ npm test

すると、下記のようなメッセージが表示され、失敗したテストの件数と詳細が表示されました。

E2E
  1) should fail

0 passing (41ms)
1 failing

1) E2E
    should fail:

    AssertionError: expected true to equal false
    + expected - actual

    -true
    +false

ブラウザ操作の自動化

以上でテストの実行環境は整いました。次は、Puppeteerを使ったブラウザ操作の自動化に進みましょう。そのためにはまず、ファイルの先頭でライブラリを読み込む必要があります。

// ライブラリを読み込む
const puppeteer = require('puppeteer');

ブラウザの起動と終了

基本のテストコードにPuppeteerを使ったブラウザの立ち上げから終了までのコードを加えると、下記のようになります。

const puppeteer = require('puppeteer');
const { expect } = require('chai');
describe('E2E', () => {
  it('should fail', async () => {
    // ブラウザを立ち上げる
    const browser = await puppeteer.launch();
    // ブラウザのタブを開く
    const page = await browser.newPage();
    // URLにアクセスする
    await page.goto('http://localhost:3000');
    // アサーションを実行する
    expect(true).to.equal(false);
    // ブラウザを閉じる
    await browser.close();
  });
});

テストコードにasync/await関数が使われていることに気が付いたでしょうか。it()はPromiseをサポートしているため、非同期処理が完了するまでテストが勝手に終了することはありません。これで、Puppeteerを使ったテストが問題なく実行できるようになりました。

テストコードの共通化

しかし、実際にはブラウザの立ち上げや終了といったコードは、テストのたびに繰り返し同じパターンが発生してしまいます。そのため、必ずすべてのテスト前に実行されるbeforeEach()や、テスト後に実行されるafterEach()を活用することで、下記のような繰り返しの少ないコードに書き換えられます。

const puppeteer = require('puppeteer');
const { expect } = require('chai');
describe('E2E', () => {
  let browser, page;
  // 必ずテスト前に実行される関数
  beforeEach(async () => {
    browser = await puppeteer.launch();
    page = await browser.newPage();
  });

  // 必ずテスト後に実行される関数
  afterEach(async () => {
    await browser.close();
  });

  // 実際のテストの内容はこれだけ
  it('should fail', async () => {
    await page.goto('http://localhost:3000');
    expect(true).to.equal(false);
  });
});

もちろん、beforeEach()およびafterEach()もPromiseをサポートしているため、同様にasync/await関数が使えます。

機能テストの流れ

テストには、大きく分けて機能テストと非機能テストの2つがあります。

機能テストは、それぞれの機能が「何をするのか」を検証するテストのことを指します。具体的には、ボタンをクリックしたらどの表示が切り替わるのか、といった視覚的にわかりやすいものが一般的です。

それに対して非機能テストは、実行時のパフォーマンスや、コードの信頼性、セキュリティ、UXUser Experienceといった、機能以外に「どのように動作するのか」を検証するテストを指します。

同期処理のテスト

まずは、ボタンのクリック回数のカウンタと、リンクの登録フォームの機能テストを実装しましょう。

ブラウザ上でJavaScriptを実行する

クリック回数のカウンタは、ネットワークリクエストを伴いません。このようにボタンのクリックと同時にブラウザの表示が切り替わる同期処理のテストは、競合状態を意識する必要がありません。

そこでまずは、第2章で学んだpage.click()page.evaluate()を組み合わせて、ページを表示しているブラウザ上で、JavaScriptを実行するテストを書いてみましょう。

// カウンターのテスト
it('increments number', async () => {
  await page.goto('http://localhost:3000');
  // ボタンをクリックする
  await page.click('button');
  // ブラウザ上でJavaScriptを実行する
  const text = await page.evaluate(() => {
    const paragraph = document.querySelector('p');
    return paragraph.innerText;
  });
  // メッセージが切り替わったことを確かめる
  expect(text).to.include('1 times');
});

テストコードを簡略化する

Pageインスタンスには、page.$eval()という便利なメソッドが用意されています。このメソッドは、ブラウザ上でdocument.querySelector()を実行し、その評価結果をJavaScriptの引数として受け取れます。このメソッドを活用することで、先ほどのテストを下記のようにさらに簡略化できます。

it('increments number', async () => {
  await page.goto('http://localhost:3000');
  await page.click('button');
  // セレクタの評価結果をJavaScriptに渡して実行する
  const text = await page.$eval('p', element =>
    element.innerText
  );
  expect(text).to.include('1 times');
});

上記のコードを、テストが失敗していたもとのit()と置き換えて保存してください。この状態でテストコマンドを実行すると、先ほどまで失敗していたテストが問題なく通るはずです。

E2E
  ✓ increments number (1142ms)

1 passing (2s)

非同期処理のテスト

しかし、ネットワークリクエストを伴う場合は、先ほどのようにはうまくいきません。このテストサイトのリンクの登録フォームは、サーバからレスポンスを受け取るまで、画面の表示が切り替わらない仕様になっています。

非同期処理を考慮しないテストコード

まずは、このような非同期処理のことを意識せず、先ほどの同期処理と同じようにテストを書いてみましょう。すると、下記のようなテストコードになります。

// リンクの登録フォームのテスト
it('adds new link', async () => {
  await page.goto('http://localhost:3000');
  // リンクのタイトルを入力する
  await page.type(
    'input[name=title]',
    'Example Domain'
  );
  // リンクのURLを入力する
  await page.type(
    'input[name=url]',
    'http://example.com/'
  );
  // フォームを送信する
  await page.click('input[name=submit]');
  // セレクターの評価結果をJavaScriptに渡して実行する
  const count = await page.$$eval('a', elements =>
    elements.length
  );
  // リンクが登録されたことを確かめる
  expect(count).to.equal(5);
});

ここで 出 てきたpage.$$eval()は、先ほどのpage.$eval()とよく似ています。このメソッドは、ブラウザ上でdocument.querySelector()の代わりに、document.querySelectorAll()が実行される点だけが異なっています。

このコードは、一見問題なく動作しそうに見えます。しかし、このコードを加えて実際にテストを実行すると、下記のメッセージが表示され、高い確率でテストが失敗してしまいます。

E2E
  ✓ increments number (945ms)
  1) adds new link
  
1 passing (4s)
1 failing

1) E2E
  adds new link:

  AssertionError: expected 4 to equal 5
  + expected - actual

  -4
  +5

デバッグモードに切り替える

なぜテストが失敗したのか、原因をデバッグモードで確認してみましょう。

まずは、Puppeteerの起動オプションにdevtoolsフラグを渡して、デベロッパーツールが開くようにコードを修正します。しかし、デバッグ実行のたびに起動オプションを書き換えるのは面倒です。

そこで、環境変数に応じてデバッグ実行が切り替えられるようにしましょう。Node.jsでは、process.envを通じて環境変数にアクセスできます。環境変数を使って起動オプションを切り替えるコードは、下記のようになります。

const options = process.env.DEBUG
  ? {devtools: true} // デバッグ実行時のオプション
  : {}; // 通常時のオプション
browser = await puppeteer.launch(options);

DEBUGの環境変数なしで実行するとデベロッパーツールが開かないので、毎回起動オプションを削除する手間が省けます。デバッグモードでのテストの実行は、下記のコマンドです。

端末8 macOS/Linuxの場合
$ EBUG=true npm test
端末9 Windowsの場合
> set EBUG=true
> npm test

デバッガを埋め込む

この状態でデバッガを埋め込むと、JavaScriptの実行が一時停止され、デバッグモードが開始されます。デバッガを埋め込む方法は、ブラウザ上で実行されるJavaScript中でdebugger;を宣言するだけです。

今回は、page.$eval()のコールバック関数がブラウザ上で実行されるため、下記のようにデバッガを埋め込みます。

const count = await page.$$eval('a', elements => {
  debugger; // デバッガを埋め込む
  return elements.length;
});

このようにデバッガを埋め込んだ状態でテストを実行すると、図3のようにJavaScriptの実行が一時停止され、デバッグ実行が可能になります。

画面を見ると、debugger;が実行された時点では、elementsの要素数が5ではなく、リンクの登録が一覧に反映されていないことがわかります。このように、非同期処理によって画面の表示が切り替わる場合は、競合状態が発生してしまい、実行結果が反映される前にテストが実行されてしまう可能性があります。

表示が切り替わるまで待機する

競合状態を避けるためには、下記のようにpage.waitFor()を使って、表示が切り替わるまで待機するのが最も確実でしょう。ただし、フォームの送信と待機を並列で実行するためには、Promise.all()を使用する必要があります。

先ほどのpage.click()を使ってフォームを送信していた行を、以下のように書き換えてください。

// 複数の非同期処理を並列で実行する
await Promise.all([
  // 登録されたリンクが表示されるまで待機する
  page.waitFor('//a[text()="Example Domain"]'),
  page.click('input[name=submit]')
]);

この状態で何度テストを実行しても、先ほどまでのようにテストが失敗することはありません。

なお、.waitFor()の引数として渡した//a[text()="Example Domain"]は、要素内のテキストが「Example Domain」のアンカーリンクとマッチするXPathを意味します。CSSセレクタではこのような複雑な条件指定はできないため、XPathによる指定が非常に柔軟であることがわかります。

非機能テストの流れ

これまで、Puppeteerを使った機能テストを見てきました。そこで次は、それぞれの機能が「何をするのか」ではなく「どのように動作するのか」を検証する非機能テストを見ていきましょう。

非機能テストには、パフォーマンスの計測のような、機能テストとは異なるAPIが求められます。

Puppeteerは、サポートブラウザをChromeに限定することで、ほかのSeleniumのようなツールにはない、非機能テストに特化した機能がサポートされています。そこで、非機能テストの具体例として、パフォーマンスとカバレッジの計測を順に取り上げていきます。

パフォーマンスを計測する

Navigation Timing API

ブラウザのパフォーマンスを計測するための最も一般的な方法の一つは、Navigation Timing APIを利用することでしょう。このAPIはW3CWorld Wide Web Consortiumによって策定されているため、Chromeだけでなく、ほとんどのモダンブラウザでサポートされています。当然Puppeteerを使っても、page.evaluate()を通じてこのAPIにアクセスできます。

const metrics = await page.evaluate(() => {
  const timing = window.performance.timing;
  // 一度JSON化することで、
  // オブジェクトをシリアライズ可能にする
  return JSON.parse(JSON.stringify(timing));
});
// ログに出力する
console.log(metrics);

なお、window.performance.timingはシリアライズ可能なオブジェクトではないため、そのままJavaScriptの実行結果として受け取ることができません。そこで、一度JSON化してもとに戻すことで、シリアライズ可能なオブジェクトに変換する必要があります。

このAPIを通じて得られる情報には、responseEnd(レスポンスの受け取り完了時間)loadEventEnd(onLoadイベント完了時間)のような重要なものが含まれます。しかし、すべての情報がタイムスタンプであるため扱いが面倒であったり、時間以外のパフォーマンスに関連する情報が得られないという欠点があります。

Chromeに特化したAPI

そこで、Puppeteerのpage.metrics()を利用できます。このメソッドは、ChromeだけのAPIを通じて、ブラウザの処理時間の詳細な内訳や、メモリの使用量といった、Navigation Timing APIからは得られない高度な情報が得られます。下記は、このAPIから得られる情報の一部です。

JSEventListeners
イベントリスナの登録数
ScriptDuration
JavaScriptの実行時間(秒)
TaskDuration
ブラウザによる処理時間(秒)
JSHeapUsedSize
JavaScriptの使用中ヒープ領域(バイト)
JSHeapTotalSize
JavaScriptの合計ヒープ領域(バイト)

このほかにもさまざまな情報を得ることができるので、詳しく知りたい方は、APIドキュメントを参照してください。これらの情報を用いて、下記のようにパフォーマンスに関する非機能テストを書くことができます。

// パフォーマンスのテスト
it('measures metrics', async () => {
  await page.goto('http://localhost:3000');
  // ページの読み込み完了後のメトリクスを取得する
  const {
    JSEventListeners,
    ScriptDuration,
    TaskDuration,
    JSHeapUsedSize,
    JSHeapTotalSize
  } = await page.metrics();
  // イベントリスナの登録数が100未満
  expect(JSEventListeners).to.be.below(100);
  // JavaScriptの実行時間が0.5秒未満
  expect(ScriptDuration).to.be.below(0.5);
  // ブラウザによる処理時間が2秒未満
  expect(TaskDuration).to.be.below(2);
  // JavaScriptのメモリ使用率が90%未満
  const memory = JSHeapUsedSize / JSHeapTotalSize;
  expect(memory).to.be.below(0.9);
});

このようなテストを加えることで、将来的に画面が複雑化したときに、パフォーマンスの悪化を未然に検知し、防止できます。

カバレッジを計測する

パフォーマンスと同様に、コードの信頼性も重要です。将来的に機能の追加と削除を繰り返したときに、まったく使われていないJavaScriptやCSSがコードの大部分を占めてしまうことがよくあります。そうすると、コードの可読性や保守性が下がってしまい、最終的に信頼できないコードになってしまう恐れがあります。

このような問題を防ぐためには、カバレッジの計測が有効です。ここでのカバレッジとは、読み込まれたプログラムのうち、実際に実行された割合のことを指します。Puppeteerには、実行されたJavaScriptおよびCSSのカバレッジを計測するための機能が提供されています。

下記は、テストサイトにアクセスしてから、カウンタ用のボタンをクリックするまでに実行された、JavaScriptのカバレッジを計測する非機能テストです。

// カバレッジのテスト
it('measures coverage', async () => {
  // JavaScriptのカバレッジの計測を開始する
  await page.coverage.startJSCoverage()
  await page.goto('http://localhost:3000');
  await page.click('button');
  // JavaScriptのカバレッジの計測を終了する
  const coverage = await page
    .coverage.stopJSCoverage();
  let totalBytes = 0;
  let usedBytes = 0;
  for (const entry of coverage) {
    totalBytes += entry.text.length;
    for (const range of entry.ranges)
      usedBytes += range.end - range.start - 1;
  }
  // JavaScriptのカバレッジが40%以上
  expect(usedBytes / totalBytes).to.be.above(0.4);
});

さらに、この機能をIstanbulのようなカバレッジ計測ツールと組み合わせることで、より詳細に実行されたコードとされなかったコードを可視化できます。

最終的なテストコード

本章で使用したテストコードをまとめると、下記のようになります。テストには大きく分けて機能テストと非機能テストがあります。機能テストでは、同期処理のテストだけでなく、非同期処理のテストの書き方についても学びました。非機能テストでは、パフォーマンスとカバレッジのテストが書けるようになりました。

// ライブラリを読み込む
const puppeteer = require('puppeteer');
// アサーションを読み込む
const { expect } = require('chai');
// テストをグループ化する
describe('E2E', () => {
  let browser, page;
  // 必ずテスト前に実行される関数
  beforeEach(async () => {
    const options = process.env.DEBUG
      ? {devtools: true} // デバッグ実行時のオプション
      : {}; // 通常時のオプション
    // ブラウザを立ち上げる
    browser = await puppeteer.launch(options);
    // ブラウザのタブを開く
    page = await browser.newPage();
  });

  // 必ずテスト後に実行される関数
  afterEach(async () => {
    // ブラウザを閉じる
    await browser.close();
  });

  // カウンタのテスト
  it('increments number', async () => {
    await page.goto('http://localhost:3000');
    // ボタンをクリックする
    await page.click('button');
    // セレクタの評価結果をJavaScriptに渡して実行する
    const text = await page.$eval('p', element =>
      element.innerText
  );
  // メッセージが切り替わったことを確かめる
  expect(text).to.include('1 times.');
});

  // リンクの登録フォームのテスト
  it('adds new link', async () => {
    await page.goto('http://localhost:3000');
    // リンクのタイトルを入力する
    await page.type(
      'input[name=title]',
      'Example Domain'
    );
    // リンクのURLを入力する
    await page.type(
      'input[name=url]',
      'http://example.com/'
    );
    // 複数の非同期処理を並列で実行する
    await Promise.all([
      // 登録されたリンクが表示されるまで待機する
      page.waitFor('//a[text()="Example Domain"]'),
      // フォームを送信する
      page.click('input[name=submit]'),
    ]);
    // セレクタの評価結果をJavaScriptに渡して実行する
    const count = await page.$$eval('a', elements =>
      elements.length
    );
    // リンクが登録されたことを確かめる
    expect(count).to.equal(5);
  });

  // パフォーマンスのテスト
  it('measures metrics', async () => {
    await page.goto('http://localhost:3000');
    // ページの読み込み完了後のメトリクスを取得する
    const {
      JSEventListeners,
      ScriptDuration,
      TaskDuration,
      JSHeapUsedSize,
      JSHeapTotalSize
    } = await page.metrics();
    // イベントリスナの登録数が100未満
    expect(JSEventListeners).to.be.below(100);
    // JavaScriptの実行時間が0.5秒未満
    expect(ScriptDuration).to.be.below(0.5);
    // ブラウザによる処理時間が2秒未満
    expect(TaskDuration).to.be.below(2);
    // JavaScriptのメモリ使用率が90%未満
    const memory = JSHeapUsedSize / JSHeapTotalSize;
    expect(memory).to.be.below(0.9);
  });

  // カバレッジのテスト
  it('measures coverage', async () => {
    // JavaScriptのカバレッジの計測を開始する
    await page.coverage.startJSCoverage()
    await page.goto('http://localhost:3000');
    await page.click('button');
    // JavaScriptのカバレッジの計測を終了する
    const coverage = await page
      .coverage.stopJSCoverage();
    let totalBytes = 0;
    let usedBytes = 0;
    for (const entry of coverage) {
      totalBytes += entry.text.length;
      for (const range of entry.ranges)
        usedBytes += range.end - range.start - 1;
    }
    // JavaScriptのカバレッジが40%以上
    expect(usedBytes / totalBytes).to.be.above(0.4);
  });
});

まとめ

第3章では、Puppeteerを使ったE2Eテストの実現方法を紹介しました。中でも、それぞれの機能が「何をするのか」を検証する機能テストだけでなく、⁠どのように動作するのか」を検証する非機能テストも見てきました。本章ではテスト対象の環境を構築しましたが、実在するサイトをスクレイピングしてみたいという思いも芽生えたかもしれません。そこで最終章では、人気サービス「note」のスクレイピングについて解説します。

おすすめ記事

記事・ニュース一覧