PlaywrightのAPIテストでトークンを使いまわす

## はじめに

Playwrightは、WebアプリケーションのE2Eテスト行うツールとしての印象が強いですが、APIテストも実行することができます。ここでは、APIテストを書く際にトークンを使いまわす方法について説明します。

## 実現したいこと

Web API のよくある仕様として、ログイン用のAPIにユーザー認証情報を送信して、トークンを取得し、そのトークンを使って他のAPIを利用するというものがあります。
ログイン以外のAPIのテストをするときに、毎回ログイン用APIを実行してトークンを取得し、その後本来実行したいAPIを実行するのは、テストの実行時間が長くなります。
ここでは、ログイン用APIを一番最初に実行してトークンを取得して、保存しておき、他のAPIのテストでそのトークンを使いまわすという方法を実現したいこととします。

## 実現方法

### カスタムフィクスチャの定義

まず、「認証情報」「トークンを保存するファイル」「トークン」をPlaywrightの[Fixtures](https://playwright.dev/docs/test-fixtures)をとして定義します。ここで定義した「トークン」のフィクスチャをテストケースで共通して使用することになります。

```typescript:fixtures.ts
import {test as base} from "@playwright/test";
import fs from "fs/promises";
import path from "path";
export {expect} from "@playwright/test";

type TestFixtures = {
  credentials: {
    username: string;
    password: string;
  };
  authFile: string;
  token: string;
};

export const test = base.extend<TestFixtures>({
  // 認証情報
  credentials: async ({}, use) => {
    const credentials = {
      username: process.env.USERNAME || "",
      password: process.env.PASSWORD || "",
    };
    await use(credentials);
  },
  // トークンを保存するファイル
  authFile: async ({}, use) => {
    const authFile = path.resolve(`.auth/user.json`);
    use(authFile);
  },
  // トークン
  token: async ({authFile}, use) => {
    const data = await fs.readFile(authFile, "utf-8");
    const {token} = JSON.parse(data);
    await use(token);
  },
});
```

テストケースを実装するときには`@playwright/test`からインポートした`test`ではなく、ここで定義した`test`をインポートして使用します。

### ログイン → トークンの保存

ログイン用のAPIを実行して、トークンを取得し、トークンを保存する部分を実装します。

```typescript:tests/login.setup.ts
import { test as setup, expect } from "./fixtures";

setup.describe("ログイン", () => {
  setup("トークンを発行する", async ({request, credentials, authFile}) => {
    const response = await request.post(`/login`, {
      data: {
        username: credentials.username,
        password: credentials.password,
      },
    });
    expect(response).toBeOK();
    const body = await response.json();
    expect(body).toHaveProperty("token");

    await fs.writeFile(authFile, JSON.stringify(body));
  });
});
```

ログイン時に必要な認証情報は、`fixtures.ts`で定義した`credentials`フィクスチャを使用して取得します。トークンは、`authFile`フィクスチャで指定したファイルに保存します。
なお、テストの前処理を実装するときは、慣例として`import { test as setup }`として、`setup`という名前でインポートします。

### テストケースでのトークンの利用

保存したトークンをテストケースを利用します。
ここでは、トークンを`Authorization`ヘッダーにセットして、APIを実行する例を示します。
テストを実装するファイル内で、[`test.use`](https://playwright.dev/docs/api/class-test#test-use)を使って[`TestOptions.extraHTTPHeaders`](https://playwright.dev/docs/api/class-testoptions#test-options-extra-http-headers)の値を変更します。これにより、このファイル内でのすべてのリクエストに`Authorization`ヘッダーが追加されます。

```typescript:tests/api.test.ts
import { test, expect } from "./fixtures";

test.use({
  extraHTTPHeaders: {
    Authorization: `Bearer ${token}`,
  },
});

test.describe("check application is active", () => {
  test("GET /api/health", async ({ request }) => {
    const response =
      await test.step("Act: request to API", async () => {
        return await request.get(`/api/users`);
      });

    await test.step("Assert: validate response", async () => {
      expect(response.status()).toBe(200);
      const body = await response.json();
      expect(body).toMatchObject({
        active: true,
      });
    });
  });
});
```

### トークンの取得を一番最初に実行するように設定

テストケースを実行する前に、トークンを取得するように設定します。`playwright.config.ts`の`projects`で、`setup`プロジェクトを定義し、`api`プロジェクトの`dependencies`に指定します。これにより、`api`プロジェクトを実行する前に、`setup`プロジェクトが実行されます。

```typescript:playwright.config.ts
import {defineConfig} from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "setup",
      testMatch: "tests/*.setup.ts",
      teardown: "teardown",
    },
    {
      name: "api",
      testMatch: "tests/*.test.ts",
      dependencies: ["setup"],
    },
    {
      name: "teardown",
      testMatch: "tests/*.teardown.ts",
    },
  ],
});
```

ちなみに、`teardown`プロジェクトは、テストケース実行後に実行されるプロジェクトです。テストケース実行後に必要な処理があれば、ここに実装します。

```typescript:tests/cleanup.teardown.ts
import { test as teardown } from "./fixtures";
import fs from "fs";
import fsAsync from "fs/promises";

teardown.describe("Clean files", () => {
  teardown("delete authorized token", async ({authFile}) => {
    if (fs.existsSync(authFile)) {
      await fsAsync.rm(authFile);
    }
  });
});
```

ここでは、テストケース実行後にトークンを保存したファイルを削除する処理を実装しています。

<!-- zenn article id: 90c690c5823024 -->