Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol.08 5時間から20分へ:PlaywrightとGitHub Actionsで高速・多ブラウザE2Eテスト

こんにちは。Strategic Products Engineering Unit SREグループの辻田です。

みなさんはE2Eテストを実施していますか?E2Eテストは、ユーザの操作に近いシナリオをシステム全体で検証する重要なプロセスですが、手動や複数ブラウザでのテストは時間がかかり、頻度も低くなりがちですよね。

今回は品質、信頼性向上を目的に、PlaywrightとGitHub Actionsを使ってE2Eテストの自動化を実現し、テストを高速化・多ブラウザ対応にした経験について紹介します。

なお、本記事は【Strategic Products Engineering Unitブログリレー】という連載記事のひとつです。

Before: 5時間かかっていたChromeのみのテスト

導入前は、E2EテストはChromeに限定して実行していましたが、それでも約5時間を要していました。スプシで管理されたテストケースを元に手動でテストをし、結果を確認していました。 もちろんこのプロセスでは手動での確認やトライ&エラーに時間がかかるため、頻繁にテストを実行するのは現実的ではありませんでした。

After: 4ブラウザ対応で20分に短縮

Playwrightを用いたE2Eテストの実装により、Chromeに加え、Edge、Firefox、Safariでの自動テストが可能になりました。さらに、GitHub Actionsを利用してテストの自動実行を設定した結果、全体のテスト時間は20分ほどに短縮されました。これにより、リリース前のテストの頻度を劇的に増やせました。

実施頻度は次のようになりました。

  • Before: 直近3カ月で4回程度
  • After: dev環境へデプロイするたびにテストを実行するようになり、週に1回以上の頻度でテストを実施することが可能になりました。

倍以上の頻度でテストを実施できるようになったことで、リリース前の品質保証が見込めます。*1

高頻度なテスト実行によるメリット

E2Eテストの自動化は、単にテスト工数を削減するだけでなく、テストを実行するハードルを下げることが大きなメリットです。テストが手軽に実行できることで、トライ&エラーのサイクルが加速し、サービスの品質向上につながります。

実体験として、最近では、ライブラリアップデートによる不具合を発見し、素早く修正できました。ライブラリアップデートのような全体に影響を及ぼす変更には、E2Eテストがあると非常に効果的かつ、安心してリリースできることを実感しました。

E2Eテストの壊れやすさを解消するための工夫

E2Eテストは非常に便利ですが、環境やUIのちょっとした変更でテストが壊れやすいというデメリットもあります。そのため、次の工夫を取り入れました。

要素の取得を安定化

テストでは、要素の取得方法が重要です。動的な要素や属性に依存しすぎると、UIの変更でテストが壊れる可能性が高まります。そこで、できるだけ静的な文言や正規表現、first()などを使い、要素を抽象的に指定するようにしました。

正規表現を使い、各行に対して特定のセルが存在するか確認する例:

const urlRegex = /\/admin\/sample\/?$/;
const rowRegex = /^山田 賢治 テスト株式会社/;

await page.waitForURL(urlRegex);
const rows = await page.getByRole("cell", { name: rowRegex }).all();
for (const row of rows) {
  const cells = row.locator("role=cell");
  await expect(cells.filter({ hasText: rowRegex })).toHaveCount(1);
}

静的な要素を指定し、特定のセルと同じ行のテキストボックスに入力する例:

await page
  .getByRole("cell", { name: "部署" })
  .locator("xpath=ancestor::tr")
  .locator('input[type="text"]')
  .fill(departmentName);
await page
  .getByRole("cell", { name: "メールアドレス" })
  .locator("xpath=ancestor::tr")
  .locator('input[type="text"]')
  .fill(testUserEmail);

first()を使い、複数ある中の最初の要素を取得してクリックする例:

await page.getByRole("button", { name: "支給" }).first().click();
await page.getByLabel("選択してください").click();
await page.getByRole("option", { name: `${lastName} ${firstName}`}).click();

上記のような工夫でコードの変更に強いテストを目指しました。

テスト範囲の絞り込み

すべての導線をカバーするのではなく、主要なユーザーフローに限定してテストを作成しました。すべての導線を網羅しようとすると、テスト自体が煩雑になり壊れやすくなるリスクがあります。重要なフローを確実に検証することで、シンプルかつ保守しやすいテストスイートを維持しています。

具体的には、PdMを巻き込んでテストケースを作成しました。その結果、優先度の低い機能のテストを削減できました。

GitHub Actionsでの実行時の工夫

E2Eテストはdev環境にテスト用のテナントを用意し、GitHub Actionsでテストを実行しています。

重要なポイント

  • Cloudflare WAF対応: テスト実行中はGitHub ActionsのIPアドレスを許可するため、動的にWAFルールを操作。
  • テストの失敗制限: 失敗回数に応じて早期終了。壊れたテストスイートでリソースを浪費するのを避けるのに役立つ。
  • varとsecretsの使い分け: URLやテスト用の変数など、公開しても問題のない情報はvarに、シークレット情報はsecretsに格納。秘匿情報以外はvarを使うほうが管理が楽です。

全体のフロー

dev環境へのデプロイをトリガーに、CloudflareのWAFの設定を動的に更新し、GitHub ActionsのパブリックIPからのアクセスを許可する処理を含んでいます。最後にWAFルールをクリーンアップする処理も含まれています。

name: Playwright E2E Tests
on:
  workflow_run:
    workflows: [Service Build and Deploy]
    types: [completed]

jobs:
  test:
    # Only run the job when the deployment status is successful and the environment is dev and the workflow run name is smm-web-front
    if: ${{ github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.name, '[dev][frontend]') }}
    environment: dev
    timeout-minutes: 60
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    defaults:
      run:
          working-directory: ./e2e
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright
        run: npx playwright install --with-deps

      - name: Get GitHub Actions Public IP
        id: get_ip
        uses: haythem/public-ip@bdddd92c198b0955f0b494a8ebeac529754262ff # v1.3.0

      - name: Allow GitHub Actions Public IP in Cloudflare WAF
        id: add_waf_rule
        run: |
          curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/rulesets/${{ secrets.CLOUDFLARE_CUSTOM_RULESET_ID }}/rules" \
          -H 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' \
          -H 'Content-Type: application/json' \
          --data '{
            "action": "skip",
            "action_parameters": {
              "ruleset": "current"
            },
            "expression": "(ip.src eq ${{ steps.get_ip.outputs.ipv4 }})",
            "description": "Allow GitHub Actions IP",
            "enabled": true,
            "position": {
              "index": 2
            }
          }' | tee response.json

          export CLOUDFLARE_RULE_ID=$(jq -r '.result.rules[] | select(.description == "Allow GitHub Actions IP") | .id' < response.json)

          if [ "$CLOUDFLARE_RULE_ID" == "null" ]; then
            echo "Failed to create Cloudflare WAF rule."
            exit 1
          else
            echo "Extracted ID: $CLOUDFLARE_RULE_ID"
            echo "CLOUDFLARE_RULE_ID=$CLOUDFLARE_RULE_ID" >> $GITHUB_ENV
          fi

      - name: Wait for domain to be reachable from GitHub Actions IP
        run: |
          for i in {1..10}; do
            if curl -s --head --request GET ${{ vars.BACKEND_API_URL }} | grep "HTTP/2 200" > /dev/null; then 
              echo "Domain is reachable from GitHub Actions IP"
              break
            fi
            echo "Waiting for domain to be reachable..."
            sleep 10
          done

      - name: Run Playwright tests
        run: npx playwright test --max-failures=5
        env:
          REUSE_EXISTING_SERVER: true
          BACKEND_API_URL: ${{ vars.BACKEND_API_URL }}
          TEST_USER_EMAIL: "general@sansan.com"
          TEST_USER_PASSWORD: ${{ vars.TEST_USER_PASSWORD }}
          TEST_USER_NAME: "太郎"

      - name: Remove GitHub Actions Public IP from Cloudflare WAF
        if: always() && steps.add_waf_rule.outcome == 'success'
        run: |
          curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/rulesets/${{ secrets.CLOUDFLARE_CUSTOM_RULESET_ID }}/rules/${{ env.CLOUDFLARE_RULE_ID }}" \
          -H 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' \
          -H 'Content-Type: application/json'

ワークフローの概要

  1. トリガー: dev環境のデプロイが成功した後、[dev][frontend]で始まるワークフローが完了したときに実行されます。

  2. 依存関係のインストール:

    • リポジトリのコードをチェックアウトし、Node.jsとPlaywrightをインストールします。
  3. WAFルールの設定:

    • GitHub ActionsのパブリックIPを取得し、Cloudflare WAFに一時的に許可ルールを追加します。
  4. テストの実行:

    • Playwrightのテストを実行。テストは最大5回まで失敗を許容します。
  5. WAFルールの削除:

    • テスト後、WAFに追加したルールを削除し、アクセス制限を元に戻します。

これにより、クラウド環境でのE2Eテストが安全かつ効率的に行われます。

まとめ

E2Eテストの自動化は、開発プロセス全体の効率化とサービスの品質向上に大きく貢献します。CIで自動化することで、テスト工数を削減し、リリース前のトライ&エラーを繰り返すことが容易になります。今後も、さらに自動化の範囲を広げ、サービスの品質向上に努めます。

最後に、Strategic Products Engineering Unit SRE Groupでは一緒に働く仲間を募集しています! open.talentio.com

*1:まだリリース数が少ないため定量的な指標は出せていないです。

© Sansan, Inc.