Ryota Murakami's Tech Blog

JavaScript、Reactが中心のブログです👨‍💻

Reactアプリにpuppeteer + JestでE2Eテスト書いてみた

最近puppeteerでのE2Eテストに興味を持っていて個人事業で開発している勤怠管理アプリに早速適用してみました。

地味で見にくいですが

  • Auth0でGoogleログイン -> クロックインボタンを押す -> クロックアウトボタンを押す -> ログアウトする

という動線をテストしています。
以下Jestに記載したテストコードです。(一つのitに全動作詰め込んでいるのは直したい)

import puppeteer from 'puppeteer'
const sel = id => `[data-test="${id}"]`
const googleId = process.env.TEST_GOOGLE_ACCOUNT
const googlePassword = process.env.TEST_GOOGLE_ACCOUNT_PASSWORD
jest.setTimeout(100000)
let page
let browser

beforeAll(async () => {
  browser = await puppeteer.launch({ headless: false, timeout: 0 })
  page = await browser.newPage()
})

afterAll(() => {
  browser.close()
})


describe('ReactのSPAにpuppeteerでE2Eテスト書いてみた', () => {
  it('GoogleIDログイン -> メイン画面へ遷移 -> クロックイン -> クロックアウト -> ログアウト', async () => {
    // トップページを表示
    await page.goto('http://localhost:3000', { waitUntil: 'networkidle2' })

    // ログインボタンをクリック
    await page.waitForSelector(sel('sign-in-btn'))
    await page.click(sel('sign-in-btn'))

    // Auth0のダイアログでGoogleログインを選択
    const googleSignInBtn =
      '#auth0-lock-container-1 > div > div.auth0-lock-center > form > div > div > div:nth-child(3) > span > div > div > div > div > div > div > div > div > div > div.auth0-lock-social-buttons-container > button'
    await page.waitFor(2000) // なぜかこのwaitが無いとタイムアウトで落ちたり落ちなかったりする
    await page.waitForSelector(googleSignInBtn)
    await page.click(googleSignInBtn)

    // GoogleIDを入力
    const mailInput = '#identifierId'
    await page.waitForSelector(mailInput)
    await page.type(mailInput, googleId)

    // nextボタンをクリック
    await page.click('#identifierNext')

    // パスワードを入力
    const passwordInput = '#password input[type="password"]'
    await page.waitFor(1000) // なぜかこのwaitが無いとセレクタ見つけられない
    await page.waitForSelector(passwordInput)
    await page.type(passwordInput, googlePassword)

    // next ボタンをクリック
    await page.click('#passwordNext')
    await page.waitForNavigation()

    // メイン画面への遷移が成功し、ログアウトボタンとクロックインボタンが表示されること
    await page.waitFor(2000) // waitForSelector()だとなぜかうまく行かない
    const logoutBtn = await page.$(sel('logout-btn'))
    expect(logoutBtn !== null).toEqual(true)
    expect(logoutBtn.constructor.name).toEqual('ElementHandle')
    let clockInBtn = await page.$(sel('clock-in-btn'))
    expect(clockInBtn !== null).toEqual(true)
    expect(clockInBtn.constructor.name).toEqual('ElementHandle')

    // クロックインボタンクリック
    await page.click(sel('clock-in-btn'))

    // クロックインが完了し、クロックアウトボタンが表示されていること
    await page.waitForSelector(sel('clock-out-btn'))

    let clockOutBtn = await page.$(sel('clock-out-btn'))
    expect(clockOutBtn !== null).toEqual(true)
    expect(clockOutBtn.constructor.name).toEqual('ElementHandle')

    // クロックアウトボタンをクリック
    await page.click(sel('clock-out-btn'))

    // クロックアウトが完了し、クロックインボタンが表示されていること
    await page.waitForSelector(sel('clock-in-btn'))

    clockInBtn = await page.$(sel('clock-in-btn'))
    expect(clockInBtn !== null).toEqual(true)
    expect(clockInBtn.constructor.name).toEqual('ElementHandle')

    // ログアウトボタンをクロック
    await page.click(sel('logout-btn'))

    // ログアウトが完了し、ログインボタンが完了していること
    await page.waitForSelector(sel('sign-in-btn'))

    let logInBtn = await page.$(sel('sign-in-btn'))
    expect(logInBtn !== null).toEqual(true)
    expect(logInBtn.constructor.name).toEqual('ElementHandle')
  })
})

一時期はSeleniumに傾倒してSelenium Ver1からWebDriverへの変遷と仕組み、PageObjectとは何ぞや?とかFirefoxアドオンのSelenium IDEを試したりして触れてはいたのですが実戦導入するまでは至っていなかった感じです。

近年はnightwatchとか使いやすいWebdriverラッパーライブラリも登場していてSeleniumを扱う敷居も下がってるんだと思いますがpuppeteerは本家chromeの開発チームがメンテしているライブラリでchrome devtoolsプロトコルという手段でchromeを操作しており、Seleniumとは別物です。

chromeでだけテストを実施できれば十分なのであればSeleniumサーバ、Webdriverインストールの手間が省略できるのでpuppeteerは良い選択だと思います。

現状の課題とか

環境の準備が面倒

E2Eテスト用のアカウントを用意したり専用のバックエンド環境を用意するハードルが高いと思う。
本番環境のAPIとかに毎回繋ぐわけにも行かないと思うのでDBの状態によって結果が変わるなどの副作用が無いクリーンなバックエンド環境を用意してテストランナー経由でアプリが起動する時はそちらに通信先を切り替えるように設定する必要がある。
この勤怠管理アプリはバックエンドにGraphcoolを使用しており、GraqhQLの特性上エンドポイントが1つしか存在せずそこを差し替えれば全てのQuery,Mutationが開発環境へ投げられるので特別な工夫は必要なさそう。

puppeteerの挙動に馴染むまで少し時間がかかる

APIのショートハンドが沢山定義してあるためかドキュメントが長くて最初は要領が掴めない。
「要素が現れるまで待機」とか「セレクタを指定してクリック」とかWebdriverの操作体系とかなり親和性があるので過去の経験を使い回せて助かった。
初めのうちはWaitの挙動がいまいち分からなくて、以下の記事に助けられた。

MEMO: PuppeteerでSPA(Single Page Application)を操作する時の留意点 - Qiita

フルブラウザモードとheadlessモードの挙動差

フルブラウザモードとheadlessモードの挙動は完全に同一ではないようで今のテストだとheadlessモードの時だけ見つからないセレクタがあってタイムアウトして落ちる。
でもわざわざheadlessモードでE2Eテストする意義って何なんだろう、自分の場合人間のブラウザポチポチを自動化するのがE2Eテストの目的だから人間が触るフルブザウザで実行出来れば十分だし、そちらの方がより本番に近い気がするので好ましい。

やはりE2Eテストの実行は時間がかかる

今回の例では1スイートの実行に20秒かかっていて、大体1スイート1分以内に収まらないと継続的に保守するのは厳しいと感じる。
E2Eテストの性質上テスト対象の状況にたどり着くまでたくさんの操作が必要となるので1Spec1AssertionなどSpecを適切に分離しようとすると毎Specごとに初期状態からアプリを操作する手順が入り実行時間が著しく増加してしまうのも悩ましいところ。

まとめ

ツールに恵まれているとはいえE2Eは行いたい操作手順のスクリプトを記述する -> 期待通りに動くか確かめる往復の時間コストがかなり重いです。
全てのケースを網羅するのは現実的でないので特にメジャーでクリティカルなユースケースに絞って実施するのが良いのかなぁと思いました。

E2Eは唯一アプリが期待通りに動作する事を担保する強力なテストだから存在する事によるバックアップされる感が半端ないしこれから先の未来手動テストしないで済む事を考えたら確実に時間的コストもペイすると思います。

以下の@akamecoさんの記事のようにテスト対象の要素を取得するセレクタを工夫すれば機能変更くらいでしかE2Eテストを書き換える必要が無くなるはず。

qiita.com