NuxtアプリをGitLabにpushしたときに自動でE2Eテストする(JestとPuppeteer)

Git, Vue.js/Nuxt.js

WEBアプリをリリースするたびに、いつも人力でフロント結合テストを上から下までガンバってやってたんですが、さすがにエンジニアの工数割くのももったいないし、外部に委託できる内容でもないしで開発のネックになってたんですよね。

コード書いて自動でテストしてくれたら楽だなーと思って、腰を据えて書いてみたら想像の3倍苦労したので、備忘録がてら記事にしてみました。
あ〜あ、自動テストも自動で書いてくれる世界になったらいいのに。

まず、これからやることを整理すると…

  1. Nuxt.jsのプロジェクトをGitLabにpush/mergeすると、
  2. E2E(End to End)テストが自動で走って、
  3. すべてOKだったらスクショをDLできる(うまくDLできず、保留中です。。)

フロントサイドのデグレ・スタイル崩れを減らしたいんじゃ!というのと、結合テストの項目を減らしたいという目的から、今回自動テスト導入に至りました。

JestというFacebook製のテストツールと、PuppeteerというChromeをHeadless(ブラウザGUIなし)で動かせるライブラリ。
これらをガッチャンコしたプリセット、それが jest-puppeteer です!

スポンサーリンク

jest-puppeteerを導入する

$ yarn add -D puppeteer jest-puppeteer

まずはjest-puppeteerを導入します。

それからプロジェクトにいくつかファイルを作成していきます。
jest-puppeteer.config.js をプロジェクトのルート直下に作成します。

module.exports = {
  launch: {
    headless: true,
    slowMo: 10
  },
  server: {
    command: 'yarn run testServer',
    port: 3000,
    launchTimeout: 50000
  }
}

続いてルート直下に jest.e2e.config.js を追加します。

module.exports = {
  verbose: true,
  preset: 'jest-puppeteer'
}

package.json にテスト実行用のスクリプトを追加します。

{
    ...
    "scripts": {
        ...
 +      "test": "jest",
 +      "test:e2e": "jest --config jest.e2e.config.js --runInBand ./test/e2e",
 +      "testServer": "nuxt build && nuxt start --port 3000"
    },
    ...
}

実際にe2eテストを動かす test:e2e スクリプトを用意し、
テスト実行時にテストサーバーが自動で立つように、 testServer スクリプトを用意します。

jest-puppeteerのコードを書く

プロジェクト/test/e2e/ ディレクトリを作成し、その中に index.spec.js を作成します。
こちらが全体のサンプル。あくまで挙動を確認する用なのでheadlessではない(ブラウザが表示される)し、テストサーバーも使いません。

const puppeteer = require('puppeteer')

describe('E2E TEST', () => {
    let browser
    let page
    let navigationPromise
    // テストサーバー使用時は 'http://127.0.0.1:3000/'
    const url = 'https://www.google.com/'

    // テスト起動前
    beforeAll(async () => {
        browser = await puppeteer.launch({
            headless: false,
            slowMo: 50
        })
        page = await browser.newPage()
        navigationPromise = page.waitForNavigation()
        // ビューポート
        await page.setViewport({ width: 1500, height: 880 })
        jest.setTimeout(30000)
    })

    it('1: レンダリング', async () => {
        await page.goto(url)
        // 遷移を待機
        await navigationPromise
        // bodyが読み込まれるまで待つ
        await page.waitForSelector('#body')
        // スクリーンショットを保存
        await page.screenshot({ path: `google.png`, fullPage: true })
        // Googleの文字があったらOK
        await expect(await page).toMatch('Google')
    })

    it('2: 文字列入力と検索実行', async () => {
        // inputが読み込まれるまで待つ
        await page.waitForSelector('input[title="検索"]')
        // 検索ウィンドウに'puppeteer'と入力
        await page.type('input[title="検索"]', 'puppeteer')
        // 検索ボタンを押す
        await page.click('input[type=submit]')
        // スクリーンショットを保存
        await page.screenshot({ path: `google-search.png`, fullPage: true })
        // Googleの文字があったらOK
        await expect(await page.title()).toEqual('puppeteer - Google 検索')
    })

    // ユニットテスト終了
    afterAll(async () => {
        await page.waitFor(1000) // ミリ秒
        await browser.close()
    })
})

※行末セミコロンは省略してます。

サンプルコードではGoogleのトップページのレンダリングと、検索窓に文字を入力して検索実行できるかという2項目の簡易的なテストをしています。

jest-puppeteerの全体の構成

ここで、index.spec.js の構成についてざっくり説明します。

describe('E2E TEST', () => {
    beforeAll(() => {
    })
    beforeEach(() => {
    })
    it(('test1', () => {
    })
    it(('test2', () => {
    })
})

jest-puppeteerの大まかな構成はjestのルールに従っています。
describeを一つの塊として「E2E TESTを実施するよ〜」と宣言し、その中にテストコードと、テストに付随する前処理・後処理を書いていくイメージです。

注意してほしいのは、テストケース以外の関数は上から実行されるわけではなく、決まった順番で呼ばれるということです。

  1. beforeAll
  2. beforeEach
    1. (テストごと)
    2. afterEach
    3. it
    4. beforeEach
  3. afterEach
  4. afterAll

続いて、関数の説明とサンプルコードを紹介します。構成とテストマッチャーについて詳しく知りたい方はこちらのQiitaの記事が非常に参考になるのでどうぞ(丸投げ)

beforeAll(describe内で最初に1度だけ呼ばれる関数)

beforeAll(async () => {
    browser = await puppeteer.launch()
    page = await browser.newPage()
    navigationPromise = page.waitForNavigation()
    // ベーシック認証
    await page.setExtraHTTPHeaders({
        Authorization: `Basic ${Buffer.from(`${BASIC_NAME}:${BASIC_PASS}`).toString('base64')}`
    })
    // ビューポート
    await page.setViewport({ width: 1500, height: 880 })
    jest.setTimeout(30000)
})

テストの前に、ブラウザの起動とページの用意、ベーシック認証の設定などをあらかじめ準備しなければいけません。テスト内で最初に行う処理を beforeAll に書いていきます。

beforeEach(テストケース実行前に毎回呼ばれる関数)

beforeEach(async () => {
})

テストケースの前に毎回呼ばれる関数です。僕は特にやることが思いつかず使いませんでした。

テストケース後に毎回呼ばれる afetrEach も存在します。

it または test(テストケース)

it(('test', () => {
    expect(await page).toMatch('Google')
})

ここにテストケースを記述していきます。
ブラウザ上で行う操作(クリック、入力、ページ遷移など)はPuppeteerを使って書きます。

Puppeteerの記述方法については別記事にまとめる予定なので、少々お待ちを。
またはこちらのQiitaの記事にほとんど書いてあるので参考にしてみてください(またか!)

テスト完了後(afterAll)

afterAll(async () => {
    await page.waitFor(1000) // ミリ秒
    await browser.close()
})

テスト完了後には必ずブラウザプロセスを終了します。
そうしないと実行しているサーバーやコンテナ内にプロセスが残り続けてしまうためです。
waitForを入れているのは、テストケースがすべて完了する前にブラウザが閉じられてしまうことがあったためです。

ローカルでテスト実行してみる

$ yarn test:e2e

だいたいのテストコードを書き終えたら、実際にローカルでテストを動かしてみましょう。

すべての項目がOKだったとき

テスト中にPuppeteerが撮影したスクリーンショットもちゃんとローカルに保存されています。(何故かこの検索結果のページはfullPage指定でも全画面にならなかったけど)

google-search.png

NG項目があったとき

わざと結果と異なるようにして、テストを失敗させてみました。

きちんとどこでなぜNGになったかが表示されます。
ページのタイトルが期待する文字列と違うと、以下のように表示されます。

※実行時にエラーが出る場合はPolyfillを入れる必要があるかもです。ちょっとこのへんは記憶が曖昧です。

$ yarn add @babel/polyfill

GitLab-CI上でテスト実行してみる

.gitlab-ci.yml

e2e_test:
  stage: test
  before_script:
    - apt-get -y install build-essential ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 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 lsb-release wget xdg-utils
    - yarn -D
  script:
    - yarn test:e2e
  artifacts:
    paths:
      - e2e-test
  rules:
    - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "staging"'
      when: always
    - if: '$CI_COMMIT_REF_NAME =~ /^feature/'
      when: manual

GitLab CIでテストを実行するには、.gitlab-ci.yml に test の STAGE を用意して、scriptの中で yarn test:e2e を実行する記述をすれば、自動でパイプラインに追加されます。
失敗したときも、先程コンソールで出たように詳細なエラー内容がログから辿れるのでとても便利です。

しかしrulesを設定しないと、commitごとに毎回テストが走ってしまいます。
なので僕は

「developブランチとstagingブランチにマージするとき」 → 必ず自動実行
「featureとつくブランチをcommitしたとき」 → マニュアル実行

というルールを作りました。

CIのパイプラインからスクリーンショットをDLする

テストはCIのコンテナ内で行われ、テスト中に撮影されたスクリーンショットたちもそこに保存されているので、確認することができません。

artifacts にパスを設定すると、ジョブの成果物を取り出す(DLする)ことができる…はずなんですが、パスの設定が悪いのか、知識不足な僕はまだうまく取れたことがないです。。
分かり次第更新します。すみません。

CIのテスト結果を通知する

CIがコケたときにSlackで通知したりメール送ったりすることも可能です。
今回の記事の内容からは逸れるのでこちらの記事を参考にしてください。

ここまでできれば、E2Eテストは完成したも同然です。テストの粒度を細かくしたり、精度を高めたりしながら、フロントの結合テストをしなくてよくなる日も近いかも。

参考にさせていただいたサイト

Jest
・Getting Started · Jest
・Facebook製のJavaScriptテストツール「Jest」の逆引き使用例 – Qiita

puppeteer
・Nuxt.js に後から E2E テスト (puppeteer, jest-puppeteer) を入れる – Qiita
・【Node.js】puppeteer基本情報&逆引き – Qiita
・JestとPuppeteerでお手軽(Visual)レグレッションテスト – Qiita

GitLab CI
・GitLab CIとPuppeteerを使ってはてなブログのデザインを継続的にデプロイする – pixiv inside – pixiv inside
・GitLab CI/CDパイプライン設定リファレンス(日本語訳:GitLab CI/CD Pipeline Configuration Reference) – Qiita



Warning: Undefined array key "sns_fb_appid" in /home/tatsugooon/t-hsn.com/public_html/tech/wp-content/themes/luxeritas/inc/sns-cache.php on line 86

Warning: Undefined array key "sns_fb_appsec" in /home/tatsugooon/t-hsn.com/public_html/tech/wp-content/themes/luxeritas/inc/sns-cache.php on line 86

Warning: Undefined array key "sns_fb_apptoken" in /home/tatsugooon/t-hsn.com/public_html/tech/wp-content/themes/luxeritas/inc/sns-cache.php on line 86