📝

テストコードに集中するための工夫(fixtures設計)

に公開

この記事は以下2つのアドベントカレンダーの12/10投稿分です。
https://adventar.org/calendars/11977
https://adventar.org/calendars/12262

Tsukuba.specアドカレ(12/9)にて、PlaywrightのFixturesについて少し触れました。
https://note.com/ydn_/n/n3cfd7fbe04dc

今回はこの記事で言及していたfixtures設計について、テストコードに集中するための工夫としてSkillnoteで実践しているよ、という内容をお話しします。

Fixturesおさらい

PlaywrightのFixturesセクションでは以下のように紹介されています。

テストフィクスチャは各テストの環境を構築するために使用され、テストに必要なものだけを提供します。テストフィクスチャはテストごとに独立しています。フィクスチャを使用すると、共通の設定ではなく、テストの意味に基づいてテストをグループ化できます。

https://playwright.dev/docs/test-fixtures

私の理解としては「依存性注入をPlaywrightが自動でやってくれる仕組み」と捉えています。
この仕組みには以下のようなメリットがあります。

  • テストコードはより短く、読みやすくなる
  • 再利用性の高いテスト基盤が 簡単に作れる
  • 初期化や後処理の重複をなくせる

簡単な例で理解を深めましょう。

もしFixturesを使わずにテストを書いたとしましょう。
pageインスタンスを作成するには以下のような手順を踏む必要があります。

test('example', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('/dashboard');
});

しかしビルトインFixturesである pageを使用すると以下のように簡潔に書くことができます。

test('example', async ({ page }) => {
  await page.goto('/dashboard');
});

pageを注入したことで、browserやcontextといった業務ドメイン外のテストコードの記載がなくなり、より本質的な処理を書くことができるようになりました。
これがfixturesのベーシックな恩恵です。

PageObjectをfixturesに集約する

さて、Fixturesについて理解が深まったところで、これを活用する方法を考えてみます。

1つはこの記事で紹介したロギングをfixturesに統合するような使い方です。

今回はPage Object Modelを適用している自動テスト基盤に対して、PageObjectをテストコード側で初期化せずに使えるような方法を考えます。

まずはfixturesに持ってくる

test.extendでloginPageをテストコードから呼び出せるようにしましょう。

// fixtures/fixtures.ts
import { LoginPage } from '@pages/login';
import { test as base } from '@playwright/test';

type Fixtures = {
  loginPage: LoginPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
});

export { expect } from '@playwright/test';

テストコード側では @playwright/testではなく、fixturesに注入したファイルからimportしてきます。

// example.spec.ts
import { test } from './fixtures/fixtures';

test('example', async ({ loginPage }) => {
  await loginPage.goto();
}

しかし、この書き方には少し難しさがあります。
それは「テストコード実装者はPageObject名を知っている必要がある」ということです。
またPageObject間のネスト構造(/mypage/approval 等を表現しづらいです。

そのためfreeeさんの以下の記事を参考に、PageObjectをまとめるクラスを作成し、これをfixturesとして登録するような仕組みに変更しました。
https://developers.freee.co.jp/entry/freee-qa-advent-calendar2024-day11

AppインスタンスにPageObjectをまとめる

// fixtures/app.ts
export class App {
  constructor(private readonly getPage: () => Page) {}

  page = this.getPage();

  mypage = Object.assign(() => new MyPage(this.getPage()), {
    approval: () => new ApprovalPage(this.getPage()),
    setting: () => new SettingPage(this.getPage()),
  });
}

PageObjectではありませんが、pageインスタンスを取得できるプロパティも追加しています。
これによりテストコード側で直接pageインスタンスを触りたい時(例えば await page.goto()をしたい時)に、 await app.page().goto()のように呼び出せるようにしました。

また各PageObjectを呼び出す際に、ページURLをそのままチェーンメソッドで書けるようにしています。
/mypageなら app.mypage()、 /mypage/approvalなら app.mypage.approval()で呼び出すことができます。
(関数オブジェクトにプロパティを持たせ、かつObject.assignを使うことで「呼び出し可能な関数」と「子ページを返す関数」を一つの塊にしています。)

このAppクラスをFixturesに注入します。

// fixtures/fixtures.ts
type Fixtures = { app: App; };

export const test = base.extend<Fixtures>({
  app: [
    async ({ page }, use) => {
      setPage(page);
      store.app = new App(currentPage);
      await use(store.app);
      store.app = null;
      setPage(null);
    },
    { auto: true },
  ],
});

export { expect } from '@playwright/test';

setPageやら見慣れないメソッドがありますが、これは fixtures/store.tsで定義しています。

// fixtures/store.ts
import type { BrowserContext, Page } from '@playwright/test';
import { App } from './app';

type Store = {
  page: Page | null;
  app: App | null;
};

export const store: Store = {
  page: null,
  app: null,
};

export const currentPage = (): Page => {
  return store.page;
};

export const setPage = (page: Page | null) => {
  store.page = page;
};

export const app = (): App => {
  return store.app;
};

※サンプルコードのため、例外処理や不要なメソッドは除外して記載しています。
※Skillnoteの自動テスト基盤では fixtures/store.tsで、テスト中にページコンテキストを切り替えたり、特定のstorageStateを持ったページインスタンスを生成するようなメソッドが含まれています。テスト中に動的にページを切り替えるケースが多くあるため、 currentPageや setPageといった窓口を持たせています。そのため、fixturesでページを初期化し、storeに登録 → Appインスタンスから参照する流れにしています。

ここまでfixturesを設計できると、テストコードではこのように書くことができます。

test('example', async ({ app }) => {
  await app.mypage().goto();
  await app.mypage.approval().goto();
});

これにより・・・

  • テストを書く人が PageObject を覚えなくてよい
  • URL階層とPageObjectの階層を一致させられる
  • 業務ドメインに近い表現になるため読みやすい

これが今のSkillnote E2E自動テスト基盤で実施している「テストコードに集中するための工夫」です。

まとめ

Playwrightの公式ドキュメントで紹介されている通りにPage Object Modelを適用すると、テストコード側でPageObjectをnewする必要があります。
https://playwright.dev/docs/pom

しかし上手くfixturesを設計することで、業務ドメインに集中できるテストコード実装基板を作ることができます。
自動テストが書きたくなる(かつメンテナンスしやすい)基盤作りをこれからも続けていきます。

Discussion