Jest + PuppeteerでwebサイトのE2Eテストをやってみる

E2Eやらないとなぁと言いながら2年が経ちそうだったので、今年こそはちゃんとやろうと思います。

Puppeteer ?

ChromeをHeadlessに動かすためのライブラリ(made by Google)です。
BehatとかCucumber系も好きなのですが、Node.jsで気軽にぶん回せそう&メンテが継続的にされそうという条件だと今のところこいつが一番かなと思っています。

Jest

Reactでなにかやるときに大体セットになってついてくるテストツールです。
公式のドキュメントにPuppeteerの使い方が書かれている&仕事で使うので今回はこいつにします。

セットアップ

Jestのドキュメントにあるサンプルをみながら準備していきます。

Node.jsのバージョンを確認する

PuppeteerはNode.jsの7系以上でなければ動作しません。nvmなどを使ってバージョンを最新のLTSにあげておきましょう。

ライブラリインストール

必要なライブラリをインストールします。

$ npm i -D jest puppeteer rimraf

// これは個人的な好み。Jestのアサーションだけでいいという人は不要
$ npm i -D power-assert

Jestの設定を書く

4つほどファイルが必要になるので、用意します。

$ touch setup.js jest.config.js teardown.js puppeteer_environment.js

setup.js

Jestのテストが起動する際に毎回実行するコードを書きます。

const chalk = require('chalk')
const puppeteer = require('puppeteer')
const fs = require('fs')
const mkdirp = require('mkdirp')
const os = require('os')
const path = require('path')
const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup')

module.exports = async function() {
  console.log(chalk.green('Setup Puppeteer'))
  const browser = await puppeteer.launch({})
  global.__BROWSER__ = browser
  mkdirp.sync(DIR)
  fs.writeFileSync(path.join(DIR, 'wsEndpoint'), browser.wsEndpoint())
}

jest.config.js

Jestで読み込みさせるファイルの情報をここにまとめます。
create-react-appをよく使う関係で、「webpack.config.jsさわれ」となってないことにちょっとホッとしています。

module.exports = {
  globalSetup: './setup.js',
  globalTeardown: './teardown.js',
  testEnvironment: './puppeteer_environment.js',
}

puppeteer_environment.js

Puppeteerの環境設定系をここにまとめます。

const chalk = require('chalk')
const NodeEnvironment = require('jest-environment-node')
const puppeteer = require('puppeteer')
const fs = require('fs')
const os = require('os')
const path = require('path')

const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup')

class PuppeteerEnvironment extends NodeEnvironment {
  constructor(config) {
    super(config)
  }

  async setup() {
    console.log(chalk.yellow('Setup Test Environment.'))
    await super.setup()
    const wsEndpoint = fs.readFileSync(path.join(DIR, 'wsEndpoint'), 'utf8')
    if (!wsEndpoint) {
      throw new Error('wsEndpoint not found')
    }
    this.global.__BROWSER__ = await puppeteer.connect({
      browserWSEndpoint: wsEndpoint,
    })
  }

  async teardown() {
    console.log(chalk.yellow('Teardown Test Environment.'))
    await super.teardown()
  }

  runScript(script) {
    return super.runScript(script)
  }
}

module.exports = PuppeteerEnvironment

teardown.js

最後にテスト終了後の後始末をまとめます。

const chalk = require('chalk')
const puppeteer = require('puppeteer')
const rimraf = require('rimraf')
const os = require('os')
const path = require('path')

const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup')

module.exports = async function() {
  console.log(chalk.green('Teardown Puppeteer'))
  await global.__BROWSER__.close()
  rimraf.sync(DIR)
}

これで事前準備ができました。

テストコードを書く

テストコードはJestの書き方 + Puppeteer操作というイメージです。

__tests___/sample.js

const timeout = 5000
describe(
  '/ (Home Page)',
  () => {
    let page
    beforeAll(async () => {
      page = await global.__BROWSER__.newPage()
      await page.goto('https://google.co.jp', {waitUntil: "networkidle2"})
    }, timeout)

    afterAll(async () => {
      await page.close()
    })

    it('should load without error', async () => {
      let text = await page.evaluate(() => document.body.textContent)
      expect(text).toContain('google')
    })
})

サンプルのテストではbeforeAllでページに移動して、afterAllでクローズするというやりかたになっています。
テスト内容としては、「https://google.co.jpにアクセスすると、googleという文字が表示される」というシンプルなものです。

実案件で使えそうなテストにしてみる

思いついた範囲でざっとテストを書いてみました。
自分のサイトが実験モードのSPAなので、イベントなどでよくお会いするよしぱんさんのサイトをテスト対象にしています。

const timeout = 5000
const assert = require('power-assert')

async function getPerformanceMetrics(page) {
  const { metrics } = await page._client.send('Performance.getMetrics')
  return metrics.reduce((acc, i) => ({ ...acc, [i.name]: i.value }), {})
}

async function waitForFMP(page) {
  let doneMet = null
  while (true) {
    const data = await getPerformanceMetrics(page)
    if (data.FirstMeaningfulPaint !== 0) {
      doneMet = data
      break
    }
    await new Promise(resolve => setTimeout(resolve, 300))
  }
  return doneMet
}

describe(
  '/ (Home Page)',
  () => {
    let page
    beforeAll(async () => {
      page = await global.__BROWSER__.newPage()
      await page.goto('https://yoshipan.com', {waitUntil: "networkidle2"})
    }, timeout)

    afterAll(async () => {
      await page.close()
    })

    it('ページをエラーなくレンダリングできている', async () => {
      let text = await page.evaluate(() => document.body.textContent)
      expect(text).toContain('よしぱん')
    })
    it('正しくタイトルのテキストが表示できている', async () => {
      const text = await page.$eval('#whtatsnew', item => item.textContent);
      assert.equal(text, 'What\'s New')
    })
    it('ドロップダウンメニューのリンクがすべてhttpsになっている', async () => {
      const aHandles = await page.$$(".dropdown-menu > li > a");
      for (var i = 0; i < aHandles.length; i++) {
        const href = await page.evaluate((handle) => handle.href, aHandles[i]);
        expect(href).toContain('https')
      }
    })
    it('first meaning full paintが1秒未満である', async () => {
      await page._client.send('Performance.enable')
      await page.goto('https://yoshipan.com', {waitUntil: "networkidle2"})
      const m = await waitForFMP(page)
      const firstMeaningfulPaint = m.FirstMeaningfulPaint - m.NavigationStart
      assert(firstMeaningfulPaint < 1, true)
    })
  },
  timeout
)

テストの内容としては、

  • エラーなくページをレンダリングできているか
  • 指定したIDに正しいテキストが表示されているか
  • リンクのhrefがhttpsになっているか
  • First Meaningful Paintが指定した秒数以内に実行されているか

の4点です。雑にテスト対象は選びましたが、ちゃんとこの4つを網羅すればウェブサイトの品質チェックにはある程度使えるかなとおもいます。

テスト結果

あとはJestをコマンドラインから実行すればOKです。

$ npm test

> jest-puppeteer@1.0.0 test /Users/jest-puppeteer
> jest

Determining test suites to run...Setup Puppeteer
Setup Test Environment.
 PASS  __tests__/test1.js (5.326s)
  / (Home Page)
    ✓ ページをエラーなくレンダリングできている (7ms)
    ✓ 正しくタイトルのテキストが表示できている (6ms)
    ✓ ドロップダウンメニューのリンクがすべてhttpsになっている (9ms)
    ✓ first meaning full paintが1秒未満である (1921ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        5.387s
Ran all test suites.
Teardown Puppeteer
Teardown Test Environment.

ちゃんと動作していますね。

使い所

今回は省略していますが、たとえば「管理画面にちゃんとログインできているか?」などもこのやり方でテストすることができます。あとはスクリーンショットの撮影やPDFでの保存もできます。

ウェブサイトを構築される際に手順書を残すくらいルーチン化されているものがあれば、Jest + Puppeteerで自動化してみるというのも1つかもしれません。

参考資料

Comment