🎃

Playwrightを良い感じにセットアップしてみた

2023/04/30に公開

はじめに

最近プロジェクトでPlaywrightを利用したE2Eテストを導入することになりました。
導入にあたって、円滑にテストを作成・実行できるために下記の要件を満たす仕組みを構築しました。

  1. テスト対象の環境を指定して実行できる
  2. データの不整合が発生しない
  3. コードのメンテナンス性を損なわない

一般的なWebサービスであれば再利用できる可能性があるため、本記事を備忘録として残します。

テスト対象の環境を指定して実行できる

当然ですが、E2Eの実行時にはテスト対象の環境(dev/stg/prod等)を指定できないと不便です。
公式を見るとdotenvで環境変数を渡す方法は記載されていますが、環境毎に切り替えるベストプラクティスは紹介されていませんでした。
https://playwright.dev/docs/test-parameterize

解決方法

テスト実行時に下記のように環境変数TARGETで対象環境を指定するようにしました。

実行コマンド
TARGET=stg npx playwright test

playwright.config.ts ではTARGETの値を使って、読み込むenvファイルを決定します。
下記のように環境毎にenvファイルを用意することで対応できます。

  • .env.dev
  • .env.stg
  • .env.prod
playwright.config.ts
import fs from 'fs';
import path from 'path';
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
import dotenv from 'dotenv';

if (process.env.TARGET) {
  dotenv.config({
    path: path.resolve(__dirname, '.', `.env.${process.env.TARGET}`),
  });
} else {
  // Read from default ".env" file.
  dotenv.config();
}

またenvファイルではテスト対象のURLやログインする際に利用するアカウント情報を管理します。

例).env.stg
BASE_URL=ステージング環境のURL
PRIMARY_USER_EMAIL=テストで利用するメインアカウントのメールアドレス
PRIMARY_USER_PASS=テストで利用するメインアカウントのパスワード

データの不整合が発生しない

今回は下図のようにテスト対象のアプリケーションにログインして特定のリソースに対してCRUD操作を行うテストケースが必要でした。

気をつけたポイントは下記を避けれるように、E2E終了時はデータベースは開始時と同じ状態になる仕組みを用意することです。

  • 途中でテストケースが失敗したら、テストが中断されてデータ不整合が発生する
  • データ不整合によって、本来通るはずのテストが失敗する
  • 必ず最後のテストケースでリソースの削除を行う必要がある運用は辛そう

解決方法

公式で紹介されているglobalTeardownを使うことで、すべてのテストケースの完了後に実行する処理を指定できます。
https://playwright.dev/docs/test-global-setup-teardown

globalTeardownが実行されるイメージは下図の通りです。

playwright.config.ts にはglobalTeardownの実行処理を書いたファイルパスを指定します。

playwright.config.ts
...
/**
 * See https://playwright.dev/docs/test-configuration.
 */
const config: PlaywrightTestConfig = {
...
  globalTeardown: require.resolve('./global-teardown'),
...
}
global-teardown.ts
const globalTeardown = async (config: FullConfig) => {
	// TODO: テスト開始時と同じ状態になるための操作を行う
};
export default globalTeardown;

注意点

実は上記の対応だけでは、途中でテストケースが失敗した場合にglobalTeardownが複数回実行されてしまいます。
理由については公式で説明されておりますので、詳しいWorker Processの動きなどはこちらをご覧ください。

ざっくり説明するとテストケースが失敗時に別のプロセスが立ち上がり、残りのテストケースを実行していきます。
なんとglobalTeardownはプロセス毎に実行されるため、立ち上がったプロセスの数だけ実行されてしまいます。
この課題についてはSerial modeを使うことで、失敗時には残りのテストケースをスキップし、プロセスの増加を回避します。

コードのメンテナンス性を損なわない

テストケースを追加していく中で、冗長的な部分が2点出てきました。

  • テストケース毎にログイン処理を実行している
  • 画面の各操作について再利用ができていない

テストケース毎にログイン処理を実行している

改善前は下記のように毎回ログインを行っていましたが、これだと無駄にログインに行っているため、テストに掛かる時間が増加しやすい作りになってしまっています。
理想は最初の1回だけログインを行い、以降はセッションを再利用することでログイン回数を最小限にすることです。

tests/example.spec.ts(改善前)
...
const login = (page) => {
  ...
};
test.describe('CRUDテスト', () => {
  // テストケース毎にログインを実行
  test.beforeEach(async ({
    page,
  }) => {
    await login(page);
  });
  // 各テストケース
  test('create', async ({page}) => {
    ...
  });
  test('read', async ({page}) => {
    ...
  });
  ...
});

改善前

理想

解決方法

前述したglobalTeardownと同様の解決方法になりますが、Playwright起動時だけ実行できるglobalSetupを利用します。

playwright.config.ts には下記の変更を行います

  • sessionを維持できるようcookieを保存するファイル( state.json )を生成しています。
  • globalSetupの実行処理を書いたファイルパスを指定します。
  • テストケースで利用するブラウザがsessionを再利用できるようstorageStateに state.json を指定します。
playwright.config.ts
...

// if not exist, make state.json
if (!fs.existsSync('./state.json')) {
  fs.writeFileSync('./state.json', '{}');
}

/**
 * See https://playwright.dev/docs/test-configuration.
 */
const config: PlaywrightTestConfig = {
  ...
  globalSetup: require.resolve('./global-setup'),
  ...
  /* Configure projects for major browsers */
  projects: [
    {
      name: 'chromium',
      use: {
        storageState: 'state.json',
        ...devices['Desktop Chrome'],
      },
    },
  ...
}

global-setup.ts ではログインを行いcookieをstate.jsonに保存を行います。

global-setup.ts
// 手動でブラウザを起動するためのヘルパー関数
const getSimpleSetting = (project: FullProject) => {
  const { timeout } = project;
  const { locale, headless } = project.use;
  return { timeout, locale, headless };
};
export const getDefaultBrowser = async (config: FullConfig) => {
  if (!config.projects[0]) {
    throw new Error('There is not a single project set. Please check.');
  }
  const { timeout, locale, headless } = getSimpleSetting(config.projects[0]);
  const browser = await chromium.launch({ headless });
  const context = await browser.newContext({
    locale,
    storageState: 'state.json',
  });
  return {
    browser,
    context,
  };
};

const globalSetup = async (config: FullConfig) => {
  // ブラウザの初期化
  const { context } = await getDefaultBrowser(config);
  context.clearCookies();
  // TODO: ログインするための画面操作
  const page = await context.newPage();
  ...
  // ログイン後はセッションを維持するためcookieをstate.jsonに保存する
  await page.context().storageState({ path: 'state.json' });
};
export default globalSetup;

テストケースも改善前はbeforeEachでloginを行っていた箇所を削除できたことで、シンプルになりました。

tests/example.spec.ts(改善後)
...
test.describe('CRUDテスト', () => {
  // 各テストケース
  test('create', async ({page}) => {
    ...
  });
  test('read', async ({page}) => {
    ...
  });
  ...
});

画面の各操作について再利用ができていない

画面操作が多いテストケースを書いていると、さっき書いたコードをまた書いてしまっていることが頻繁に起きました。
下記は単純な例ですが、画面項目が多いほど煩雑になっていき、可読性が低下していくことが目に見えています。

tests/example.spec.ts(改善前)
...
test.describe('CRUDテスト', () => {
  // 各テストケース
  test('create', async ({page}) => {
    ...
    // 登録するために入力フォームを全部埋めていく
    await page.getByPlaceholder('お名前').fill('テスト');
    ...
  });
  test('create form validate', async ({page}) => {
    ...
    // createのテストケースで似たようなコードを書いている
    await page.getByPlaceholder('お名前').fill('a'.repeat(101)); 
    await expect(page.getByText('100字以内で入力してください。')).toBeVisible();
    ...
  });
  ...
});

解決方法

公式で紹介されているPage object modelsパターンを採用しました。
https://playwright.dev/docs/pom
このデザインパターンは1画面をクラス化して、画面の項目をプロパティ、画面の操作をメソッドとして定義することで、画面操作を再利用することが容易になります。
シンプルな例ですが、下記のような感じです。

pages/CreatePage.ts
import type { Locator, Page } from '@playwright/test';
export class CreatePage {
  readonly page: Page;
  readonly name: Locator;
  readonly phoneNumber: Locator;
  readonly createButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.name = page.getByPlaceholder('お名前');
    this.phoneNumber = page.getByPlaceholder('電話番号');
    this.createButton = page.getByRole('button', { name: '作成' });
  }

  async goto() {
    await this.page.goto(`${process.env.TARGET}/create`);
  }
  
  getName() {
    return this.name.inputValue();
  }

  getPhoneNumber() {
    return this.phoneNumber.inputValue();
  }

  setName(name:string) {
    return this.name.fill(name);
  }

  setPhoneNumber(phoneNumber:number) {
    return this.phoneNumber.fill(number);
  }
  
  create() {
    await this.createButton.click();
  }
}

クラス化したおかげで、画面操作が抽象化され見通しが良くなりました。

tests/example.spec.ts(改善後)
...
test.describe('CRUDテスト', () => {
  // 各テストケース
  test('create', async ({page}) => {
    const createPage = new CreatePage(page);
    await createPage.goto();
    ...
    await createPage.setName('テスト');
    ...
    await createPage.create();
  });
  test('create form validate', async ({page}) => {
    ...
    await createPage.setName('a'.repeat(101)); 
    await expect(page.getByText('100字以内で入力してください。')).toBeVisible();
    ...
  });
  ...
});

まとめ

以上がPlaywright導入時に色々と試行錯誤しながら改善した結果です。
導入したばかりなので、今後も改善するべき箇所が次々と出てくると思いますが、頑張っていこうと思います。

Discussion