🎼

Playwrightで効率と効果を最大化するためのルール

2024/11/08に公開

PlaywrightによるE2Eテスト開発で、開発効率とテスト効果を高めるため、以下のルールを設定しています。

  • プロダクト基準でディレクトリを構成
    • 目的:複数のプロダクトを一元管理したい
    • 方法:一つのリポジトリ内に、プロダクト単位でディレクトリを分割する
  • テストレポートの出力形式の調整
    • 目的:Playwrightで作成されるテスト結果をプロダクト開発関係者全員に共有したい
    • 方法:
      • テストレポートを履歴として保存できるように、テスト結果ディレクトリの名称を一意とする
      • テストレポートをクラウド上で閲覧できるよう、htmlファイルを出力する
      • さらにテストレポートを分析できるよう、jsonファイルも出力する
  • テストシナリオとテストケースの分離
    • 目的:冗長性を排除したい
    • 方法:
      • テストシナリオとなる単体テスト向けspec.ts、または総合テスト・E2Eテスト向けtest.tsと、テストケースとなるコードを分離する
      • ソースコードをレイヤーで整理する
  • テストシナリオ毎に実行時間を最適化
    • 目的:テスト実行の合計時間を最小化したい
    • 方法:
      • configファイルのtimeoutに、最小の実行時間を設定する
      • 実行時間が長く必要とされるテストシナリオで、test.setTimeout関数を利用して実行時間を調整する
  • テストステップに説明を付記
    • 目的:テストに失敗したシナリオがあった際に、Playwrightのテストレポートで失敗した箇所を特定しやすくしたい
    • 方法:Playwrightに用意されたtest.step関数を利用して、処理の内容を記載する
  • テストステップで無駄となる操作を削減
    • 目的:ユーザー操作の待機や繰り返し操作を行わせない
    • 方法:操作する前に、アサーションで操作対象の要素があることを常に確認する
  • テストコードの柔軟化
    • 目的:運用保守フェーズ中に、Webサイト改修によるテストコード修正を減らしたい
    • 方法:
      • CSSセレクタやXPathによる要素指定を行わない
      • 公式が推奨するLocatorsを利用する

プロダクト基準でディレクトリを構成

ディレクトリ構成は、クラウド環境での構築を想定した構成です。
仮に、プロダクト単位でPlaywrightのリポジトリを用意すると、リポジトリの数だけインフラ環境を用意する必要があります。
そのため、プロダクト毎にPlaywrightのリポジトリを用意するのではなく、一つのリポジトリに全プロダクトのテストシナリオを集約しています。
以下にディレクトリ例を示します。

.
├── package-lock.json
├── package.json
├── playwright.config.ts ... デフォルトのPlaywrightの設定ファイル
├── playwright.sample_product.config.ts ... プロダクト毎のPlaywrightの設定ファイル
├── src ... 複数のプロダクトを対象とした、Playwrightのソースコードを配置するディレクトリ
│   └── sample_product ... テストを実施したいプロダクト名
│       ├── models ... 画面の要素を定義するコードのためのディレクトリ
│       │   ├── searchPage.ts
│       │   └── topPage.ts
│       ├── services ... データを加工するコードのためのディレクトリ
│       │   └── url.ts
│       └── tests ... テストケースのディレクトリ
│           ├── checkTopPageLink.ts
│           ├── gotoTopPage.ts
│           └── searchPage.ts
└── test-plans ... Playwrightのテストシナリオディレクトリ
    └── sample_product ... テストを実施したいプロダクト名
        └── playwright_website.test.ts ... テスト対象の実行方法を記載したPlaywrightの構成ファイル

テストレポートの出力形式の調整

テスト結果は、品質保証関係のチームはもちろん、開発者、またプロダクト開発の責任者が自由にいつでも閲覧できる必要があります。
Playwright標準のレポート機能は、必要最低限の集計結果が整理されていて、かつHTMLで出力できるため、S3などに保存すればそのままクラウド上で閲覧できます。
またPlaywrightでは、json形式のテスト結果も合わせて出力できるため、jsonファイルを解析してテスト結果をSlackやメール、データウェアハウスに連携がしやすいです。
ただし、Playwrightのレポートファイルは、常に同じディレクトリに出力されるため、履歴としてS3などに保存するためには、ディレクトリを一意に変更する必要があります。
以下に設定ファイルconfig.tsの例を示します。

playwright.config.ts
/**
 * レポートディレクトリを環境変数から指定します
 */
const reportDirectory: string = path.resolve('./', 'playwright-report', process.env.REPORT_DIR ?? '');

export default defineConfig({
    /**
     * テスト実行対象となるテストシナリオを含むディレクトリを指定します
     */
    testDir: './test-plans/sample_product',
    /**
     * 最小のテスト実行時間を設定します
     */
    timeout: 60 * 1000,
    fullyParallel: true,
    forbidOnly: !!process.env.CI,
    retries: 1,
    workers: process.env.CI ? 1 : undefined,
    /**
     * レポートは、HTML及びjson形式で出力するよう設定します
     * また、出力ディレクトリを一意となるよう変更します
     */
    reporter: [
        ['html', {open: 'never', outputFolder: reportDirectory}],
        ['json', {outputFile: path.resolve(reportDirectory, 'summary.json')}],
    ],
    use: {
        trace: 'retain-on-failure',
        screenshot: "only-on-failure",
        video: 'on',
    },
    projects: [
        {
            name: 'chromium',
            use: {...devices['Desktop Chrome']},
        },
    ],
});

テストシナリオとテストケースを分離

テストシナリオを作成する際に、defineConfigtestDirに指定されるテストシナリオファイルのみにコーディングすると、単体のファイルサイズが大きくなり視認性が悪化します。
またシナリオによっては、画面間の往復など、複数のテストシナリオで同じテストケースを再利用したい場合も考えられます。
そこで、テストシナリオとテストケースを分離します。
以下にソース例を示します。

test-plans/playwright_website.test.ts
test.beforeEach(async ({page}: PlaywrightTestArgs): Promise<void> => {
    await gotoTopPage(page);
});

test('トップページのGet startedリンクの挙動を確認',
    {tag: ['@sample_product', '@sp_top']},
    async ({page}: PlaywrightTestArgs): Promise<void> => {
        await checkTopPageLink(new TopPage(page, 'Installation'));
    });

test('検索機能を確認',
    {tag: ['@sample_product', '@sp_search']},
    async ({page}: PlaywrightTestArgs): Promise<void> => {
        /**
         * テストシナリオ毎にテスト実行時間を拡張します
         */
        test.setTimeout(2 * 60 * 1000);
        await searchPage(new SearchPage(page, 'playwright', 'PlaywrightAssertions', 'PlaywrightAssertions'));
    });
src/sample_product/tests/checkTopPageLink.ts
/**
 * トップページの`Get started`リンクの挙動を確認します
 * @param page トップページモデル
 */
export async function checkTopPageLink(page: TopPage): Promise<void> {
    await page.clickLink();
}
src/sample_product/tests/searchPage.ts
/**
 * 検索単語から希望の画面が表示されることを確認します
 * @param page 検索ページモデル
 */
export async function searchPage(page: SearchPage): Promise<void> {
    await page.search();
    await page.checkTitle();
}

また、ソースコードをレイヤーで管理し、冗長性を排除することで、改修の影響箇所を最小化します。
以下にソースコード例を示します。

src/sample_product/models/searchPage.ts
/**
 * Playwright公式サイトの検索機能を確認するモデル
 */
export class SearchPage {
    private readonly searchWord: string;
    private readonly searchButton: Locator;
    private readonly searchField: Locator;
    private readonly candidateLink: Locator;
    private readonly titleHeading: Locator;

    constructor(page: Page, searchWord: string, expectedCandidate: string, expectedTitle: string) {
        this.searchWord = searchWord;
        // buttonに紐づいている、aria-labelの名称をもとに要素を指定します
        this.searchButton = page.getByRole('button', {name: 'Search', exact: true});
        this.searchField = page.getByPlaceholder('Search docs', {exact: true});
        this.candidateLink = page.getByRole('link', {name: expectedCandidate, exact: true});
        this.titleHeading = page.getByRole('heading', {name: expectedTitle, exact: true});
    }

    /**
     * 検索入力欄をクリックします
     */
    private async clickSearchField(): Promise<void> {
        await test.step('入力欄をクリック', async (): Promise<void> => {
            /**
             * ユーザー操作を行う前に、HTML要素が表示されていることを事前に確認します
             */
            await expect(this.searchButton).toBeVisible();
            /**
             * HTML要素が表示されていることを確認できた後、ユーザー操作を行います
             */
            await this.searchButton.click();
        });
    }

    /**
     * 検索単語を入力します
     */
    private async inputSearchWord(): Promise<void> {
        await test.step('検索単語を入力', async (): Promise<void> => {
            await expect(this.searchField).toBeVisible();
            await this.searchField.fill(this.searchWord);
        });
    }

    /**
     * 検索で期待される候補をクリックします
     */
    private async selectCandidate(): Promise<void> {
        await test.step('検索結果のリンクをクリック', async (): Promise<void> => {
            await expect(this.candidateLink).toBeVisible();
            await this.candidateLink.click();
        });
    }

    /**
     * ページを検索します
     */
    async search(): Promise<void> {
        await test.step('単語を入力して検索', async (): Promise<void> => {
            await this.clickSearchField();
            await test.step('検索結果の表示と選択', async (): Promise<void> => {
                await this.inputSearchWord();
                await this.selectCandidate();
            });
        });
    }

    /**
     * 検索結果から遷移したページのタイトルを確認します
     */
    async checkTitle(): Promise<void> {
        await test.step('期待されるページのタイトルを確認', async (): Promise<void> => {
            await expect(this.titleHeading).toBeVisible();
        });
    }
}
src/sample_product/models/topPage.ts
/**
 * Playwright公式サイトのトップページのリンクを確認するモデル
 */
export class TopPage {
    private readonly getStartedLink: Locator;
    private readonly expectedTitle: Locator;

    constructor(page: Page, expectedTitle: string) {
        this.getStartedLink = page.getByRole('link', {name: 'Get started', exact: true});
        this.expectedTitle = page.getByRole('heading', {name: expectedTitle, exact: true});
    }

    async clickLink(): Promise<void> {
        await test.step('Get startedリンクをクリック操作', async (): Promise<void> => {
            await this.getStartedLink.click();
            await expect(this.expectedTitle).toBeVisible();
        });
    }
}
src/sample_product/services/url.ts
/**
 * Playwrightの公式サイトのURLを取得します
 * @param subDirectory 任意のサブディレクトリ
 */
export function getPage(subDirectory?: string): string {
    const uri = 'https://playwright.dev';
    if (subDirectory === undefined) {
        return uri;
    } else {
        return new URL(subDirectory, uri).href;
    }
}

テストシナリオ毎に実行時間を最適化

E2Eテストツールによるテスト実行は、ヘッドレスで実行した場合でも、ブラウザを起動して画面操作を伴うため、完了するまで時間がかかります。
またPlaywrightでは、公式の記事通り、ユーザー操作に待機機能や自動リトライ機能が含まれ、操作を実行できなかった場合、テスト実行時間最大まで待機や繰り返し操作が行われます。
そこでテストシナリオに、テストが正常に完了する最小時間を設定します。
テストレポートの出力形式の調整テストシナリオとテストケースを分離にコード例を示しました。

テストステップに説明を付記

Playwrightによるテスト実行結果は、以下の図の通り、ステップがコードで表示されます。
コードで表示されるステップ
テスト失敗時には、失敗した画面やユーザー操作を特定し、プロダクトの改修かテストケースの修正が必要となります。
しかし上図では、コードが表示されているため、失敗箇所の特定が困難です。
そこで、テストケースに記述するステップにtest.step関数を追加することで、以下の図の通り、テスト結果の可読性が高くなります。
説明文で表示されるステップ
以下にソースコード例を示します。

src/sample_product/tests/gotoTopPage.ts
/**
 * Playwrightのトップページに移動します
 * @param page PlaywrightのPageオブジェクト
 */
export async function gotoTopPage(page: Page): Promise<void> {
    /**
     * test.step関数を利用して、コードが行っている操作内容を説明します
     */
    await test.step('トップページを表示', async (): Promise<void> => {
        await page.goto(getPage());
        await expect(page.getByRole('heading', {
            name: 'Playwright enables reliable end-to-end testing for modern web apps.',
            exact: true
        })).toBeVisible();
    });
}

テストステップで無駄となる操作を削減

Playwrightは、テストシナリオ毎に実行時間を最適化でもお伝えした通り、ユーザー操作にリトライ機能が備わっています。
ユーザー操作は、HTML要素が見当たらない場合でも、繰り返し要素を探し操作を試みようとします。
そこで、公式でも紹介されている通り、アサーションでHTML要素があることを確認してから、ユーザー操作を行うと実行時間を最小化できます。
searchPage.tsclickSearchFieldメソッドに、ソースコード例を示しました。

テストコードの柔軟化

HTML要素をCSSセレクタやXPathで指定すると、プロダクトのHTML構成が変更となった場合、Playwrightのソースコードも合わせて修正の可能性が高くなります。
そこで、公式が推奨する、Locatorsを利用しましょう。
プロダクトの仕様変更で画面に改修が入った場合でも、変更を最小限に抑えられる可能性が高くなります。

Discussion