Playwright の Fixture の動作の流れを簡単に解説する

# はじめに

Playwright には [Fixture](https://playwright.dev/docs/test-fixtures) という機能があります。Playwright を使っていれば(ほぼ)必ず使っている機能ですが、その仕組みについては分かりにくい印象があります。

公式ドキュメントの Fixture の概要を翻訳すると、次のような説明になっています。
> Playwright Test は、テスト フィクスチャの概念に基づいています。
> テスト フィクスチャは、各テストの環境を確立するために使用され、テストに必要なものだけを提供します。
> テスト フィクスチャはテスト間で分離されています。フィクスチャを使用すると、共通の設定ではなく、意味に基づいてテストをグループ化できます。

この説明では少し抽象度が高く、実体がどのようなものなのか捉えづらいように感じました。そこで本記事では、Fixture の動作の流れを具体的に説明します。

# Fixture とは
Fixture とは、ざっくりいうと `テストの実行に必要なリソースを初期化・管理する仕組み（と、そのリソースを表すオブジェクト）`です。

Fixtureはテストケース毎に指定することができ、またテストケース間でFixtureは独立して設定されます。これにより、次のようなことが実現できます。
- **テスト実行環境の分離**
　テストケース間で、テストに必要なリソースを独立して持つことができる。
- **リソースの初期化とクリーンアップ**
  　テストケース実行のたびに、必要なリソースの初期化、解放ができる。
- **グルーピング**
  　Fixtureを使い分けることで、テストケースをグルーピングできる。

例えば、事前定義された `page`, `context`, `browser` Fixture を活用すると、次のようなケースに対応できます。
1. `page`: テストケース毎に、ページ（タブ）を独立して扱うケース。  
2. `context`: テストケース毎に、セッションを独立して扱うケース。
  （例：１セッションの中で複数ページを開き操作する。ログインしたユーザーが、別タブで認証の必要な別ページを開いてもログイン状態であることを確認するなど。）
3. `browser`: テストケース毎に、ブラウザを独立して扱うケース。  
  （例：１ブラウザの中で複数セッションを確立し操作する。ワークフローを伴うアプリケーションで、一般ユーザと管理者ユーザを交互に操作するなど。）

`page` の使用例として、公式のサンプルコードを見てみます。

```typescript
import { test, expect } from '@playwright/test';

test('has title', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  // Expect a title "to contain" a substring.
  await expect(page).toHaveTitle(/Playwright/);
});
```

テストを実装する `async` 関数の引数に `{ page }` を渡すことで、テスト実行時に `page` Fixture が自動的にセットアップされます。これにより、テストコード内でブラウザの1つのページ（タブ）を操作するための [Page](https://playwright.dev/docs/api/class-page)オブジェクトが利用可能になります。

ここで注目すべきは、テストコード内にブラウザの起動やセッションを独立させる処理が含まれていない点です。`page` Fixtureを使用することで、Playwright がこれらの処理を暗黙的に実行し、ページ操作の実装のみすればよい状態を整備してくれるのです。

# Fixture の動作の流れ
さて、サンプルコードでいきなり Fixture が使われているものの、以下の点が不透明です。

- Fixture はどのように初期化されるのか
- Fixture はどのように提供されるのか
- Fixture のクリーンアップはどのタイミングで行われるのか

この疑問を解消するため、カスタム Fixture を作成することでその動作を確認していきます。

公式ドキュメントの [Creating a fixture](https://playwright.dev/docs/test-fixtures#creating-a-fixture) を参考に、カスタム Fixture を作成します。

カスタム Fixture を作るためには、`test.extend()` を使い、既存の `test` オブジェクトを拡張した新しい `test` オブジェクトを作ります。ここでは、サンプルとして UUID を含んだオブジェクトを提供する Fixture を作成することにします。

Fixture は `test.extend()` の引数に、オブジェクトのプロパティとして定義します。通常、プロパティの値には async関数を定義し、第一引数に **作成対象の Fixture が依存する** Fixtureを、 第二引数に `use` 関数を指定します。この async 関数に、次の3つのステップを実装しておきます。

1. テスト実行前に実施したい、テストに必要な処理を実施したりオブジェクトを準備する。
2. `use` 関数の引数に1.で準備したオブジェクトを設定し await 式として呼び出す。
3. テスト終了後に実施したい、クリーンアップ処理を実施する。

今回のサンプルでは、それぞれ次の処理を実装します。

1. 標準出力に`"Fixture の初期化"` を出力し、UUIDを `uuid` プロパティに設定したオブジェクトを作成する。Fixture の動作確認用配列に、作成したオブジェクトをpushする。
2. 1.で作成したオブジェクトを `use` 関数の引数に設定する。
3. 標準出力に`"Fixture のクリーンアップ"` を出力し、オブジェクトの `uuid` プロパティにnullを設定する。

テストケースは3つ作成し、Fixtureの動作確認用配列の内容を出力する処理と、テストケースに設定した myFixture の内容（UUID）を出力するようにします。

実装は以下の通り。

```typescript
import { test as base, expect } from "@playwright/test";
import crypto from 'crypto';

type uuidObject = {
  uuid: string | null;
}

// 動作確認用配列
var createdFixtureList: uuidObject[] = []

// test.extend() を使い test を拡張する。
// import で { test as base } として参照しているため、base.extend() で拡張する。
const test = base.extend<{myFixture}>({
  myFixture: async ({}, use) => {
    // 準備
    console.log("Fixture の初期化");
    let obj: uuidObject = { uuid: crypto.randomUUID() };
    
    // 記録
    createdFixtureList.push(obj);
    
    // テスト関数に値を提供
    await use(obj);

    // テスト終了後のクリーンアップ処理
    console.log("Fixture のクリーンアップ");
    obj.uuid = null;
  }
});

test("Fixture の動作確認1", async ({ myFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myFixture.uuid}`);
});

test("Fixture の動作確認2", async ({ myFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myFixture.uuid}`);
});

test("Fixture の動作確認3", async ({}) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行`);
});
```

ここでは、動作の流れを理解するために worker 数を 1 とし、テストケースを直列実行します[^1]。テストの標準出力は次のようになります[^2]。※見やすさのために改行を追加しています。
```
Running 3 tests using 1 worker

[chromium] › example.spec.ts:27:1 › Fixture の動作確認1
Fixture の初期化
createdFixtureList: [{"uuid":"409d6605-25fc-433f-bf81-f872c265de14"}]
test() 実行: 409d6605-25fc-433f-bf81-f872c265de14
Fixture のクリーンアップ

[chromium] › example.spec.ts:32:1 › Fixture の動作確認2
Fixture の初期化
createdFixtureList: [{"uuid":null},{"uuid":"5ff4ad45-03f9-4473-a5df-c1abd7cde85f"}]
test() 実行: 5ff4ad45-03f9-4473-a5df-c1abd7cde85f
Fixture のクリーンアップ

[chromium] › example.spec.ts:37:1 › Fixture の動作確認3
createdFixtureList: [{"uuid":null},{"uuid":null}]
test() 実行
  3 passed (1.9s)
```

上記の結果から、テストケースの実行時に次の順序で処理が進んだことが分かります。

- Fixture の初期化処理が実行され、UUID が設定される。
- テストケースが実行される。
- Fixture のクリーンアップ処理が実行され、UUID にnullが設定される。

また、出力される UUID と `createdFixtureList` の内容から、テストケース毎に独立して Fixture が処理されることも分かりました。

`use` 関数が Promise を返していることも念のため確認しておきます。

```typescript
await use(obj)
```

を

```typescript
// テスト関数に値を提供
const x = use(obj);
console.log("promise instanceof Promise: ", x instanceof Promise);

// Promise の解決を待つ
const result = await x;
```

に変更し、再度実行すると次の出力が得られます。（1ケース分のみ記載します） 

```
[chromium] › example.spec.ts:31:1 › Fixture の動作確認1
Fixture の初期化
promise instanceof Promise:  true
createdFixtureList: [{"uuid":"01e8fed4-989a-42a6-a02d-e3cbcb7ebd8c"}]
test() 実行: 01e8fed4-989a-42a6-a02d-e3cbcb7ebd8c
Fixture のクリーンアップ
```

`use` の戻り値が Promise であり、テスト実行後にPromiseが解決され、クリーンアップ処理に移っていることが分かりました。

まとめると、Fixture は以下の流れで動作することが分かりました。

1. `test` 関数の実行前に、async 関数で必要なリソースを準備する。
2. 初期化されたリソースを `use` メソッドで `test` 関数に提供する。  
   Fixture の処理は `use` メソッドの戻り値である Promise が解決されるまで停止する。
3. テスト実行が完了すると Promise が解決され、後続に記述されたクリーンアップ処理を実行する。

# worker スコープの Fixture
Playwrightは、ワーカープロセスを使用してテストファイルを並列で実行することができます。デフォルトではCPUの論理コア数の半分のワーカープロセスが実行され、並列でテストを実行します。

ここまでは、Fixture が1テストケース毎に独立して作成されると述べてきましたが、ワーカープロセスのスコープで Fixture を再利用できるように構成することも可能です。

`worker` スコープで Fixture を構成する場合は、Fixtureを `[Fixtureの実装(async関数), { scope: 'worker'} ]` の配列形式で定義します。

実装例は以下の通り。

```typescript
import { test as base, expect } from "@playwright/test";
import crypto from 'crypto';

type uuidObject = {
  uuid: string|null;
}

var createdFixtureList: uuidObject[] = []

const test = base.extend<{myWorkerFixture}>({
  // [Fixtureの実装, { scope: 'worker'} ] として定義する。
  // 実行されているワーカープロセスのインデックスを取得するため、第3引数に workerInfo を指定しておく
  myWorkerFixture: [async ({}, use, workerInfo) => {
    // 準備
    console.log(`Fixture の初期化, ${workerInfo.workerIndex}`);
    let obj: uuidObject = { uuid: crypto.randomUUID() };
    
    // 記録
    createdFixtureList.push(obj);
    
    // テスト関数に値を提供
    await use(obj);

    // テスト終了後のクリーンアップ処理
    console.log(`Fixture のクリーンアップ, ${workerInfo.workerIndex}`);
    obj.uuid = null;
  }, { scope: 'worker' }]
});

test("Fixture の動作確認1", async ({ myWorkerFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myWorkerFixture.uuid}`);
});

test("Fixture の動作確認2", async ({ myWorkerFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myWorkerFixture.uuid}`);
});

test("Fixture の動作確認3", async ({ myWorkerFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myWorkerFixture.uuid}`);
});

test("Fixture の動作確認4", async ({ myWorkerFixture }) => {
  console.log(`createdFixtureList: ${JSON.stringify(createdFixtureList)}`);
  console.log(`test() 実行: ${myWorkerFixture.uuid}`);
});
```

この4つのテストケースを worker数を 1 として実行すると、次のような結果が得られます。

Fixtureの初期化とクリーンアップ処理は、それぞれテスト全体の開始・終了時の1回だけ実行されており、すべてのケースでUUIDが同一になっています。
```
Running 4 tests using 1 worker
[chromium] › example.spec.ts:46:1 › Fixture の動作確認1
Fixture の初期化, 0
createdFixtureList: [{"uuid":"eccf15d6-0613-41cc-93e6-bb541ba732d8"}]
test() 実行: eccf15d6-0613-41cc-93e6-bb541ba732d8

[chromium] › example.spec.ts:51:1 › Fixture の動作確認2
createdFixtureList: [{"uuid":"eccf15d6-0613-41cc-93e6-bb541ba732d8"}]
test() 実行: eccf15d6-0613-41cc-93e6-bb541ba732d8

[chromium] › example.spec.ts:56:1 › Fixture の動作確認3
createdFixtureList: [{"uuid":"eccf15d6-0613-41cc-93e6-bb541ba732d8"}]
test() 実行: eccf15d6-0613-41cc-93e6-bb541ba732d8

[chromium] › example.spec.ts:61:1 › Fixture の動作確認4
createdFixtureList: [{"uuid":"eccf15d6-0613-41cc-93e6-bb541ba732d8"}]
test() 実行: eccf15d6-0613-41cc-93e6-bb541ba732d8
Fixture のクリーンアップ, 0
  4 passed (2.2s)

```

worker数を 2 として実行すると、次のような結果が得られます。

Fixtureの初期化とクリーンアップ処理は、それぞれ2つのケースで実行されており、2つのケースでUUIDが同一になっています。
```
Running 4 tests using 2 workers
[chromium] › example.spec.ts:51:1 › Fixture の動作確認2
Fixture の初期化, 1

[chromium] › example.spec.ts:46:1 › Fixture の動作確認1
Fixture の初期化, 0

[chromium] › example.spec.ts:51:1 › Fixture の動作確認2
createdFixtureList: [{"uuid":"3c4d1502-226f-4324-8682-5522d29e758c"}]
test() 実行: 3c4d1502-226f-4324-8682-5522d29e758c

[chromium] › example.spec.ts:46:1 › Fixture の動作確認1
createdFixtureList: [{"uuid":"64ee9545-4823-4280-95ed-158643088f76"}]
test() 実行: 64ee9545-4823-4280-95ed-158643088f76

[chromium] › example.spec.ts:56:1 › Fixture の動作確認3
createdFixtureList: [{"uuid":"3c4d1502-226f-4324-8682-5522d29e758c"}]
test() 実行: 3c4d1502-226f-4324-8682-5522d29e758c
Fixture のクリーンアップ, 1

[chromium] › example.spec.ts:61:1 › Fixture の動作確認4
createdFixtureList: [{"uuid":"64ee9545-4823-4280-95ed-158643088f76"}]
test() 実行: 64ee9545-4823-4280-95ed-158643088f76
Fixture のクリーンアップ, 0
  4 passed (1.8s)
```

このように、ワーカープロセスの単位で Fixture を共有できることが分かりました。

# まとめ
本記事では、Playwright の Fixture の概要と動作の流れを簡単に説明しました。内容をまとめると次の通りです。

- Fixture は `テストの実行に必要なリソースを初期化・管理する仕組み／オブジェクト` である。
- Fixture の動作の流れは次の通り
  1. `test` 関数の実行前に、async 関数で必要なリソースを準備する。
  2. 初期化されたリソースを `use` メソッドで `test` 関数に提供する。  
     Fixture の処理は `use` メソッドの戻り値である Promise が解決されるまで停止する。
  3. テスト実行が完了すると Promise が解決され、後続に記述されたクリーンアップ処理を実行する。
- Fixture は通常テストケース毎に独立して使用されるが、ワーカープロセス単位で使用することもできる。

[^1]: worker 数の設定は `playwright.config.ts` の `workers` プロパティに設定する。
[^2]: chromium のみで実行するよう設定した。