Playwrightを使ったE2Eテストを導入した話

はじめに

こんにちは。ソーシャル経済メディア「NewsPicks」の QA/SET チームの海老澤です。 先日 弊社で E2E テスト実行するために Playwright を導入したため紹介させてください。

E2Eテストとは

E2Eテスト(エンドツーエンドテスト)とは、ソフトウェア開発におけるテスト手法の一つで、アプリケーションが実際の運用環境と同様の条件下で正しく動作することを確認するためのテストです。

システムの開始点から終了点までを通じて、ユーザーの視点でアプリケーションのフローを追い、機能全体が連携して期待通りに動くかを検証します。具体的には、ユーザーが行うであろう一連の操作をシミュレートして、データがシステムを通じて適切に流れるかや、最終的なアウトプットが正しいかどうかを確認します。E2Eテストにより、部分的な単体テストや統合テストでは見逃されがちな問題を発見することができます。

一方でユニットテストや統合テストと比較して、実行が遅く壊れやすいというデメリットもあります。 下記の図は Succeeding with Agile で提唱されたモデルですが、テストケース数の望ましい比率を表していて上に行くほど少なくなっており、自動テストの多くはユニットテストでカバーして、E2Eテストではユニットテストや統合テストでカバーできない部分を埋めていくのが理想的です。

テストピラミッド

Playwrightとは

Playwrightは、Microsoftによって開発されたオープンソースの自動E2Eテストツールです。このツールは、ウェブアプリケーションのエンドツーエンドテストを自動化するために設計されており、複数のブラウザ(Google Chrome、Mozilla Firefox、Apple Safariなど)でのクロスブラウザテストをサポートしています。

playwright.dev

Playwrightを導入した理由

弊社では E2E テスト基盤として、MagicPodWebDriver を使っていたのですが、それぞれ下記のような問題がありました。

MagicPod

  • ノーコードのツールで仕様のキャッチアップに時間がかかり、また整理もしづらいため コードで管理していきたいという要望がありました。
  • 実行が一つのサーバーで並列で実行できないという問題がありました。

WebDriver

  • version が古く、環境によっては動かない。update する選択肢もありますが作業が大変

そのため新たな E2E フレームワークを導入する方針で検討しました。

2024 時点の人気のあるフレームワークとして、Cypress と Playwright があり、その二つの中からplaywrightを選ぶことにしました。

Playwright を選んだ理由として大きいのが Playwright は並列実行可能で実行時間が短くなるためです。cypress の無料版は基本並列実行できないため(厳密には別のモジュールなどを入れてできそうだが,公式のやりかたではない。)実行時間が長くなります。 NewsPicksは機能が多く、テストケースも今後増やしていく予定のため、より実行時間が短縮できるPlaywrightを選んだ次第です。

Playwright install

playwright の install はドキュメントを参考にしてください。 npm なら

npm init playwright@latest

でインストールできます。

各項目の設定

install すると playwright.config.ts という設定ファイルが作成されます。 以下は修正した playwright.config.ts の一部になります。

// 各環境の設定値を読み込みます ※1
const envName = process.env.ENV_NAME ?? "production";
const config =
    configs.find((config) => config.envName === envName) ?? getProdConfig();

export default defineConfig({
    testDir: "./tests",
    // 下記設定でテストケースは全て並列に動きます※2
    fullyParallel: true,

    // 略

    // workers を undefined にすると 好きなだけプロセスを立ち上げてくれます※2
    workers: undefined,
    // 環境ごとにサーバースペックの違い等からresponse timeが違うため、環境ごとに設定
    timeout: config.defaultTimeout,
    expect: { timeout: config.expectTimeout },
    // テスト結果はallure を使って出力しています。https://www.npmjs.com/package/allure-playwright
    reporter: [
        ["line"],
        [
            "allure-playwright",
            {
                detail: true,
                outputFolder: "allure-results",
                suiteTitle: true,
                categories: [],
                environmentInfo: {
                    framework: "playwright",
                },
            },
        ],
    ],
    use: {
        baseURL: config.baseURL,
        // テスト失敗時のみ画像、動画を取得するようにします
        screenshot: "only-on-failure",
        video: "retain-on-failure",
    },

    projects: [
        // テスト実行前に実行するスクリプトです ※3
        {
            name: "setup",
            testMatch: /.*\.setup\.ts/,
            use: {
                ...devices["Desktop Chrome"],
            },
        },
        {
            name: "chromium",
            use: {
                ...devices["Desktop Chrome"],
            },
            dependencies: ["setup"],
        },
        {
            name: "firefox",
            use: {
                ...devices["Desktop Firefox"],
            },
            dependencies: ["setup"],
        },
        {
            name: "webkit",
            use: {
                ...devices["Desktop Safari"],
            },
            dependencies: ["setup"],
        },
        /* Test against mobile viewports. */
        {
            name: "mobile",
            use: {
                ...devices["Pixel 7"],
            },
            dependencies: ["setup"],
        },
        // 略
    ],
    // 略
});

※1 弊社ではテスト環境が複数あり、どのテスト環境でも同一テストを実行できるよう、各環境用の config を読み込みます。環境は 実行時の ENV_NAME という環境変数で選択します。

下記のように環境別にbaseUrlやtimeoutの秒数を設定しています

export const configs: Config[] = [
    {
        envName: "test-env-1",
        baseUrl: "http://test-env-1.hogehoge.com",
        defaultTimeout: 100000,
        expectTimeout: 100000,
    },
    {
        envName: "test-env-2",
        baseUrl: "http://test-env-2.hogehoge.com",
        defaultTimeout: 100000,
        expectTimeout: 100000,
    },
    // 略
    {
        envName: "production",
        baseUrl: "http://hogehoge.com",
        defaultTimeout: 30000,
        expectTimeout: 30000,
    },
];

例えば下記実行することで test-env-1という環境で実行されます.

ENV_NAME=test-env-1 npx playwright test

※2 Playwrightではデフォルトでテストファイルごとに並列実行されます。さらに fullyParallel: true と設定するとテストケースごとに並列に実行されます。 プロセス数(workers)はundefined に設定すれば適切な数プロセスを立ち上げてくれます。 その他詳細は下記リンクを参照ください。

playwright.dev

※3 テストを実行する前の共通の処理として、authentication の機能を使ってユーザーのログインを管理します。

また弊社ではユーザーの種別が複数あり(有料ユーザー、無料ユーザー、認証済みか否か など)テストごとに使うユーザーを切り分けたいため、Multiple signed in roles の仕組みを使っています。

下記が auth.setup.ts のコードです。このコードではユーザー種別ごとにログイン -> auth ファイルへの書き込みを行っています。

export type UserRoles =
  // 有料ユーザー
  | 'PAID'
  // 無料ユーザー
  | 'FREE'

export type UserConfigs = {
  [role in UserRoles]: User
}

const userConfigs = {
  // 有料ユーザー
  PAID: {
    userName: 'paid',
    password: 'XXXX',
  },
  // 無料ユーザー
  FREE: {
    userName: 'free',
    password: 'XXXX',
  },
}


Object.entries(userConfigs).forEach(([role, user]) => {
  setup(`authenticate as ${role}`, async ({ page }) => {
    // トップページに移動
    const topPage = new TopPage(page)
    await topPage.goto()

    // ログイン
    await topPage.login(user.userName, user.password)

    // authFileへ書き込み
    await page.context().storageState({ path: getAuthFile(user) })
  })
})

export function getAuthFile(user: User): string {
  return `playwright/.auth/${user.userName}.json`
}

ログインした状態でのテストを書きたい場合はテストコードの先頭に test.use~~ の一行を追記すればFREEユーザーにログインした状態でのテストが可能です。

test.use({ storageState: getAuthFile(config.userConfigs.FREE) })

test('無料ユーザーのテスト', async ({
    // 略

テストコード

弊社では Page Object Model でテストコードを書いています。 Page Object Model というのは 実際の page やコンポーネントごとの class を作りそこに要素の定義を集約する方法です。

例えば上記であげた、トップページへの移動とログインの処理ですが下記のようにトップページのクラスを作り、そこに各要素や、ページ遷移、ログインなどの動きを集約させています。

例としてのトップページのPage Object

export class TopPage {
  readonly page: Page

  readonly loginButton: Locator
  readonly usernameTextBox: Locator
  readonly passwordTextBox: Locator
  readonly loginSubmitButton: Locator

  constructor(page: Page) {
    this.page = page
    this.loginButton = page.getByRole('button', {
      name: 'ログイン',
      exact: true,
    })
    this.usernameTextBox = page.getByPlaceholder('userName')
    this.passwordTextBox = page.getByPlaceholder('password')
    this.loginSubmitButton = page.getByRole('button', {
      name: 'ログインする',
      exact: true,
    })
  }

  async goto(): Promise<void> {
    await this.page.goto('/')
  }

  async login(username: string, password: string): Promise<void> {
    await this.loginButton.click()

    await this.usernameTextBox.fill(username)
    await this.passwordTextBox.fill(password)
    await this.loginSubmitButton.click()
  }
}

テストコードの書き方含め、詳細は 下記 をご参照ください。

playwright.dev

その他Tips

Locatorの取得

vsCodeの Playwright拡張 を使って要素の取得が簡易にできます。 sidebarからPlaywrightの拡張を選び, pick locator で要素の取得が可能です。

デバッグ方法

Playwrightではデバッグ方法が多く提供されていて、こちらもテストケースを書く上でとても役立ちます。 詳細は下記ドキュメントを参照ください。

playwright.dev

導入後の反響

導入したところ、社内の開発者からも使いやすい, 書きやすい等の声が上がりました。

また開発者十数名と 2時間ほどの MagicPod から Playwrightへのテストケース移行合宿を行ったのですが、最終的に 1人 あたり 1テストケース移行のPRが作成まで行うことができました。

終わりに

今回はPlaywrightの導入について紹介させていただきました。

次回は作成したテストをクラウド基盤上で動かす方法を紹介させていただく予定です。

Page top