🔫

PlaywrightでフロントエンドのE2Eテストを自動化してみた話

9 min read

TL;DR

  • Playwrightは導入コストが高くはない
  • ひとまずスクリーンショットを全ページ撮るだけでも、QAの工数(もしくは自分の確認する時間)を削減できた
  • 意外とやってみると書ける
  • playwright-cliのコード自動生成が非常に便利!

きっかけ

プロジェクトでPlaywrightのフロントエンドのE2Eテストを書かせていただく機会がありました。
導入〜テストケース作成〜実装までやらせていただいて、知見がある程度溜まったので
まとめてみることにしました。

至らぬ点もあるかと思いますが、その際はご指摘いただけますと幸いです。

ざっくりブラウザテストのこれまで

参考:https://blog.logrocket.com/playwright-vs-puppeteer/

Selenium

  • ブラウザテストの自動化自体は新しい技術ではなく、2004年ごろからSeleniumがあった
  • しかし、Seleniumは挙動に問題があり(再現性の低いエラーで落ちるなど)さらにリソース消費が激しい (フルブラウザを起動するため)という欠点
  • 徐々に軽量なheadlessブラウザへの移行

Puppeteer

  • Googleがheadless Chromeの発表の数ヶ月後にPuppeteerをリリース
  • webdriverによる操作と異なり、直接ブラウザに対して働きかけることができるので、Seleniumより多く
    のユースケースへ対応できるようになった(ネットワークリクエストのインターセプトなど)

Playwright

  • 2020年 1/31にMicrosoftよりメジャーバージョンリリース
  • PupepeteerチームのTOP2コントリビューターがPlaywrightチームへ移動して開発
    →そのため、おおよその場合においてPlaywrightとPuppeteerのAPIが似ている
  • クロスブラウザサポート

Playwright概要

MicrosoftによるE2Eテストライブラリ
Chronium, Fifrefox, WebKitへのサポートを単一のAPIにより提供

https://playwright.dev/

なぜ(Puppeteerではなく)Playwrightか

  • PuppeteerはChrome(もしくはChronium)を前提としたE2Eテストを提供する。
    一方でPlaywrightはWebkit、Firefoxへの対応もしている。
  • Playwrightはauto-waitsにより、安全な処理手順の実行が可能になっている
    →例えば、Puppeteerの場合だと待機時間を手動で設定しておく必要があるなど、
    E2Eの一番のネックとなる部分がPlaywrightで抽象化されたので嬉しい。
  • Playwright-cliによるコードの自動生成機能をサポート
    →慣れると爆速でテストの基盤になる実装を行うことができる...が、正直言って可読性はない。
    そのため、プロジェクトレベルでコンポーネントにIDを付与するなどの対応を行った。

Playwright導入

■playwright

npm i -D playwright

https://github.com/microsoft/playwright

■playwright-cli

npx playwright --help

▲playwrightがインストール済みであれば利用可能

https://www.npmjs.com/package/playwright-cli

サンプル

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  // Create pages, interact with UI elements, assert values
  await browser.close();
})();

▲Chroniumでテストする場合(例)

const playwright = require('playwright');

(async () => {
  for (const browserType of ['chromium', 'firefox', 'webkit']) {
    const browser = await playwright[browserType].launch();
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('http://whatsmyuseragent.org/');
    await page.screenshot({ path: `example-${browserType}.png` });
    await browser.close();
  }
})();

▲Chronium/firefox/webkitでテストする場合(例)

jestと一緒に走らせたい

npm install -D jest jest-playwright-preset playwright

https://www.npmjs.com/package/jest-playwright-preset

package.json経由で設定するか、jest.config.jsで設定するか。

packgae.json
"jest": {
  "preset": "jest-playwright-preset"
}

or

jest.config.js
module.exports = {
  preset: 'jest-playwright-preset',
}

...あとは好きなE2E実行コマンドをpackage.jsonに追記。

package.json
{
  "scripts": {
    "e2e": "npm run test e2e", //E2Eテスト全件を走らせる
    "e2e:scenario": "npm run test ./__e2e__/scenario", //E2Eテストのうちシナリオ関連のみ走らせる
    "e2e:ss": "npm run test ./__e2e__/stories" //sample //E2Eテストのうちスクリーンショット撮影を行う
  }
}

これでjest内でplaywright利用ができるようになった。

sample.spec.js
beforeAll(async () => {
  await page.goto('https://whatismybrowser.com/')
})

test('should display correct browser', async () => {
  const browser = await page.$eval('.string-major', (el) => el.innerHTML)
  expect(browser).toContain('Chrome')
})

■E2Eテストは時間が非常にかかる場合が多いです。jestで起動した場合、デフォルトの時間切れになる場合が多くあるので、プロジェクト設定にtesttimeoutを設定するもしくはテストファイル上でtimeoutを設定する方法があります。

test.spec.ts
jest.setTimeout(100000) // 時間切れになるため少し緩めの上限をユーザー定義する

▲個別のテストファイルで設定する場合

インポートを相対パスでやるのが辛い時

jest.config.jsのmoduleNameMapperにE2E向けのパスを定義。
例えば@/e2e/~とか。
typescriptの場合、tsconfig.jsonに上記のパス設定を追記。

既にJestをコンポーネントに対するテストやUnitテストで利用している場合

jestのe2e向け設定を作成するなどしていました。

jest.e2e.config.js
module.exports = {
  preset: 'jest-playwright-preset',
  verbose: true,
  collectCoverage: false,
  testEnvironmentOptions: {
    'jest-playwright': {
      browsers: 【対応したいブラウザ】,
      // 以下、サンプル
      serverOptions: {
        command: process.env.CI 
          ? 'npx serve build -p 2000'
          : 'BROWSER=none npm start',
        port: 2000,
        launchTimeout: 200000,
        debug: true,
      },
    },
  },
  
  setupFilesAfterEnv: [${テスト全体に対してフックしたいteardownメソッドなどがあれば、ディレクトリを定義しておく}],
}

setupFilesAfterEnvについて、例えばですがスクリーンショットを撮影し、一覧化したいというユースケースがありました。その際にteardownメソッドとして下記のような実装を行い、スクリーンショットの一覧の作成 を行うメソッドを定義するなどしていました。

setupFilesAfterEnvの一例
import { createHtml } from './util/CreateScreenShotListHtml'

/**
 * 全てのテストが完了次第フックする
 */
afterAll(async () => {
  /**
   * スクリーンショットのSS一覧をHTML化
   */
  await createHtml().catch((e) =>
    console.log(`スクリーンショットの一覧の作成中に失敗しました: ${e}`)
  )
})

PlaywrightのAPIについて

その前に利用できるセレクタについて

よく使うのは

  1. CSSセレクタ
sample.spec.ts
await page.click('#id')
await page.click('.class')
  1. テキストセレクタ
sample.spec.ts
await page.click('text="遷移したいリンク/ボタンラベル')

になってくると思います。

一方で、複雑なセレクタが必要になった場合には$を使った方法もあります(後述)

https://playwright.dev/docs/api/class-selectors?_highlight=select

auto-waitに対応しているAPI

例えば、page.click(selector[, options])というAPIがあります。
これは単純にセレクター要素をクリックするものです。

その場合、playwrightは下記を確認してから次の操作へ移ります。

  • 要素がDOMにアタッチ
  • 要素がVisibleになる
  • 要素がStableになる(アニメーション途中 / アニメーション完了時点を終えている)
  • 要素がイベント受け取りが可能な状態である
  • 要素がEnabledになる

その辺りについて、下記にまとめられています。

https://playwright.dev/docs/actionability/

よく使うAPI / ありがちな書き方

ページ内の要素をセレクターによってクリックし、遷移する

sample.spec.ts
await Promise.all([
    page.waitForNavigation(),
    page.click('text="遷移したいリンク/ボタンラベル"'),
])

https://playwright.dev/docs/api/class-page?_highlight=waitfor#pagewaitfornavigationoptions

https://playwright.dev/docs/api/class-page?_highlight=waitfor#pageclickselector-options

フォームinputを埋める

sample.spec.ts
await page.fill('input[type="email"]', 'test@hoge.com')

https://playwright.dev/docs/api/class-page?_highlight=page.fill#pagefillselector-value-options

スクリーンショットを撮影

sample.spec.ts
await page.screenshot({
  path: 【ファイル名を含むパス】,
  fullPage: true,
})

https://playwright.dev/docs/api/class-page?_highlight=page.fill#pagescreenshotoptions

ファイルinputを埋める

sample.spec.ts
const path = require('path')

await page.setInputFiles(
  '【セレクター】',
  path.join(__dirname, `../assets/sample.png`) //パス
)

https://playwright.dev/docs/api/class-page?_highlight=page.fill#pagesetinputfilesselector-files-options

DOM要素をpageインスタンス経由で取得したい

sample.spec.ts
const table: Promise<null|ElementHandle> = await page.$('#table')
// const table = document.getElementById('table') と似ている

▲セレクターにマッチした単一の要素を返却。一致しない場合はnullを返却

sample.spec.ts
const tr: Promise<Array<ElementHandle>> = await page.$$('tr')
// const tr = document.getElementsByTagName('tr') と似ている

▲セレクターにマッチした要素全てを返却。一致しない場合は空配列を返却

https://playwright.dev/docs/api/class-page?_highlight=page.fill#pageselector-1

DOM要素に対して何らかの評価(もしくはJavaScript実行)をしたい

sample.spec.js
const href: string = await page.$eval(
  '#target-link',
  (el) => el.href
)

▲aタグのhref要素を抽出する

https://playwright.dev/docs/api/class-page?_highlight=page.fill#pageevalselector-pagefunction-arg

ひとまずこの辺りだけを抑えたら、あとはコードの自動生成をしつつ、chroniumでデバッグしていけば
大抵のE2Eを書くことはできると思います。

まとめ

  • Playwrightは導入コストが高くはない
  • ひとまずスクリーンショットを全ページ撮るだけでも、QAの工数(もしくは自分の確認する時間)を削減できた
  • 意外とやってみると書ける
  • playwright-cliのコード自動生成が非常に便利!

Playwrightの導入は意外と簡単で、まだ日が浅いOSSではありますが、Puppeteerのナレッジがしっかり受け継がれていて、既にproduction readyなプロジェクトだなと感じました!

E2Eを1から導入して感じたこと

  • 最低限、重要な画面のスクリーンショットがあるだけでも嬉しい
  • プロジェクトでE2E導入を決めたらinputタグには最低限IDを付与すると良い(理想はE2Eカバレッジ範囲全てに渡したいけど面倒)
  • テーブルのtr要素やリスト要素など、複数コンポーネントをレンダリングする際には、一意なIDを渡すと良 い

E2Eを書いてみて、nthで要素をセレクトしたり、全く読めないセレクタを使う必要が出てきたら、コンポーネント側のマークアップになるべく一意なIDを渡すと幸せになりやすいです。

また、しっかりとE2Eを実行してassertすることに重きを置かなくとも、自社開発などで自分たちでQAを最低限行う必要があるチームでは、スクリーンショットを撮影しておくだけでも品質向上には大きく貢献してくれそうだな、と感じました!

まだやれなかったこと

  • CI/CDでE2EテストのSSを撮影して、スクリーンショットの一覧HTMLを生成
    → AWS S3などにアップし、HTMLファイルを配信
    → Slackの通知に飛ばして、全画面のSSをチェック

CI/CDにE2Eを載せるのもいつかやってみたいですねー!

それでは今回はこの辺りで。

Discussion

ログインするとコメントできます