電池冷蔵庫

除雪のない世界に行きたい

Vue.js E2E test (GitLab CI + Webpack + Jest + Puppeteer)

TL;DR

 snippet & zip file for YOU!

gitlab.com

 vue-cliで自動生成されたE2Eテストの設定が意味不明だったので、自前で置き換えたものです。一応動きました。やったぜフラン!

ご注意

 PuppeteerとChromiumのリリース頻度を考えると、この記事に書いてあることは3時間くらいで陳腐化します。動かなかったら自分でなんとかしてね。

 [追記] 参考にした公式ドキュメントがあることを忘れてました。 Using with puppeteer by jest

GitLab CIとE2Eで幸せになろう

 GitLabとはgitリポジトリとdockerレジストリとcron内蔵型docker-based CIが一体化した無料で使える世界最高のサービスですが、そのことは常識なので説明しません。まさかGitLabを単なるGitHubのパチモンだなんて思っている人はいませんよね?

 E2Eテストというのは要するに「実際ちゃんと動くか」というテストですが、これまた常識なので説明は差し控えさせていただきます。決して私がよく分かってないわけではありません。

 最近では、vue-cilのwebpackテンプレートを利用するとE2Eテストが追加されるなど、E2Eは「あって当たり前」のテストになって来ています。まだ製造コストが高すぎるため「全ての画面に書いて当たり前」とまでは行きません(そうなる時代は当面来ないと思います)が、壊れたときに致命的な部分のE2Eテストを書いておくと、即死ダメージを瀕死レベルまで軽減できる可能性があります。

 クレームを入れるページとか、不具合を報告するページなど、「そこが壊れてたらもう終わり」という部分ですね。こういったセーフティネットがぶっ壊れていると救いがありませんので、是非テストしましょう。

 ただ、既存のE2Eテストはツールの使い方そのものが難しく、設定ファイルはおよそ理解不能であり、導入したが最後、誰もメンテナンスできる気がしないようなコードで構成されていました。これではやはり、プロジェクトの基盤としてはなかなか受け入れられません。

puppeteer

 ここで注目したいのが、最近にわかに流行りつつあるヘッドレスChromium操作ライブラリー puppeteer です。

 puppeteer はあくまで Chromium を操作するだけなので、複数のブラウザー (ぶっちゃけIE) を操作して、厳密に動作チェックするようなテストには使えません。しかし、「極めてクリティカルな機能の、本当に最低限の動作」を確かめるくらいなら、たとえブラウザーごとにデザインの崩れなどがあったとしても、Chromiumで動けばだいたい問題ないはずです*1

 また puppeteer は機能がシンプルである分、使い方もシンプルです。これと別にアクセス先のサーバーさえ用意すれば、E2Eテストに着手できるわけです。それならメンテできそう!というエンジニアも多いと思います。

具体的

 コード類は冒頭のスニペットからzipを取得してください。以下は一部ファイルとポイントの解説。

pacage.json

{
  "scripts": {
    "e2e": "jest --config test/e2e/jest.conf.js --forceExit"
  }, ...
}

 asyncを複数利用すると、現在のjestは壊れます。仕様なのかバグなのか知りませんが、永久にプロセスが終了しません。そこで --forceExit をつけます。

runner.js

let server
let host
let port

function path (str = '/') {
  const h = host || 'localhost'
  const p = port || process.env.PORT
  return `http://${h}:${p}/#${str}`
}

const run = async () => {
  const webpack = require('webpack')
  const DevServer = require('webpack-dev-server')
  const webpackConfig = require('../../../build/webpack.prod.conf')
  const devConfigPromise = require('../../../build/webpack.dev.conf')
  const fetch = require('node-fetch')

  const devConfig = await devConfigPromise
  const devServerOptions = devConfig.devServer
  const compiler = webpack(webpackConfig)

  server = new DevServer(compiler, devServerOptions)
  host = devServerOptions.host
  port = devServerOptions.port

  await server.listen(port, host)

  const url = path()
  console.log('server url', url)

  await fetch(url)
  console.log('dev server running!')
}

module.exports = {run, path}

 ここで行われていることを平易に言い換えると「力ずくで Webpack dev サーバーを起動し、そこにアクセスしてE2Eテストを実施する。そのとき初回ロードだけめちゃくちゃ長いので、初期設定において1回ロードできるまで待つ。こうすると次からは普通に動く」となります。クッソ力技ですね!

 ここの処理は完全に間違っているような気がしますが、とにかくこれで動く(ほかの方法も見つからない)ので、気にしない。後でちゃんと調べて直すかもしれない(願望)。

setup.js

const puppeteer = require('puppeteer')
const mkdirp = require('mkdirp')
const fs = require('fs')
const {dir, endpoint} = require('./endpoint')

module.exports = async function () {
  await require('./runner').run()

  const browser = await puppeteer.launch({args: ['--no-sandbox']})

  // store the browser instance so we can teardown it later
  global.__BROWSER__ = browser

  // file the wsEndpoint for TestEnvironments
  mkdirp.sync(dir)
  fs.writeFileSync(endpoint, browser.wsEndpoint())
}

 argsが既存の例と異なりますが、Linux SUID Sandboxのページを見ると

The Linux SUID sandbox is almost but not completely removed. See https://bugs.chromium.org/p/chromium/issues/detail?id=598454 This page is mostly out-of-date.

 とあり、なんとなく「別になくても動きそうだな?」と思ったので取りました。今のところ動いています。ただ、いまだに問題の所在が謎なので、ダメなら戻すかも…

 なお --no-sandbox も削除してみましたが、これはissueを踏み抜いてエラーになりました。したがって、これは完全にバグのワークアラウンドです。珍妙な動作なので修正してほしいですね。  

404.spec.js

const {path, browser} = require('../puppeteer').get()

describe('/404', () => {
  let page

  beforeAll(async done => {
    page = await browser.newPage()
    await page.goto(path('/404'))
    done()
  })

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

  it('loads without error', async done => {
    const text = await page.evaluate(() => document.body.textContent)
    expect(text).toContain('404')
    done()
  })

  it('contains correct email link', async done => {
    const button = await page.$('[data-prj-role="email-button"]')
    expect(await (await button.getProperty('href')).jsonValue()).toEqual('mailto:tottokotkd@me.com')
    done()
  })
})

 これが具体的なE2Eテストのサンプルです。今回はクリックなどをせず、単純にHTMLの内容を見ています。

 謎の呪文もありませんし、意味不明なコードもないと思います。await page.$('[data-prj-role="email-button"]') という部分は、プロジェクトのルールとして導入された独自のアトリビュートに基づく処理です。このような属性があるとテストが非常に楽になるので導入されていますが、もちろん必須ではありませんから、適宜IDなどで引いても問題ありません。

 どうですか? これならE2Eテストを作れそうじゃありませんか?

.gitlab-ci.yml

variables:
  PUPPETEER_DEPS: gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget
stages:
- test
build:
  stage: test
  image: node:latest
  script:
  - apt-get update
  - apt-get install -y $PUPPETEER_DEPS
  - npm install
  - npm run test

 GitLab CIでは node:latest を使ってテストを実行していきます。ただ、素の状態ではChromiumがインストールできません。

 もちろんPuppeteer実行用にイメージを作ってもいいと思うのですが、なんかそこのメンテをするのも面倒だよなということで、とりあえず毎回インストールします。node:latestDebianベースなので、必要なパッケージを apt-get しましょう。

 依存については公式ドキュメントに言及があります。

プッシュするたびE2E!

 ここまでの設定で、GitLabにプッシュするたびE2Eテストが実行され、マージリクエストを出すたびにテストが成功するか表示され、「テストが成功したら自動マージ」機能が使えるようになりました。やったぜフラン!

*1:デザインが崩れてボタンが隠れたりすると悲惨ですね。ただそもそも、そんなクリティカルなページに、ピクセル単位で正確に描写しないと操作不能になるデザインを導入するのはやめておきましょう