👏

Playwrightでテスト開始時に1度だけ認証処理をする

2023/09/28に公開

認証が必要なアプリケーションのE2EテストをPlaywrightでテストする時、どのように実装しているでしょうか?
beforeEach hookを使って各テスト前に都度、認証の処理を実行させることができますが、この場合テスト数で多くなるにつれ、テスト時間がどんどん長くなってしまいます。

Playwrightには認証機能があり、テスト開始時に1度だけ認証の処理を走らせ、以降、認証された状態で各テストを実行することができます。
1度の認証で各テストを実行できる仕組みとしては、最初の認証処理時にJSONファイルでストレージ状態(CookieとLocalStorage)を管理し、各テストでブラウザが、そのJSONファイルを読み込むことでログイン状態にする、という感じです。

今回はPlaywrightの認証機能を実装して、実際にどのように動作しているか確認してみたいと思います。

前準備

テストはT3 Stackで私が以前構築した認証付き管理画面を使って試してみます。
https://github.com/k-yaguchi/t3-stack-starter-kit

アプリケーションの作成

$ clone git@github.com:k-yaguchi/t3-stack-starter-kit.git playwright-auth-example
$ cd playwright-auth-example

playwrightインストール

$ npm init playwright@latest

playwrightをインストールした時に作成されるexample.spec.tsは不要なので削除します。

$ rm -f tests/example.spec.ts

playwright/.authディレクトリを作成します。あとでこのディレクトリ配下にストレージ情報のJSONファイルを作成するようにします。

$ mkdir -p playwright/.auth

playwright/.auth配下に作成するJSONファイルはテスト時しか利用しないので.gitignoreに追加してGit管理外とします。

$ echo -e "\n/playwright/.auth" >> .gitignore

認証処理

tests/auth.setup.tsに認証処理を記述していきます。
browserContextのstorageStateでブラウザのストレージ状態をplaywright/.auth/user.jsonに保存します。

auth.setup.ts
+ import { test as setup, expect } from "@playwright/test";
+ 
+ const authFile = "playwright/.auth/user.json";
+ 
+ setup("authenticate", async ({ page }) => {
+   await page.goto("http://localhost:3000/");
+   await page.getByRole("button", { name: "Sign in" }).click();
+   await page.waitForURL(/auth\/signin.*/);
+   await page.getByLabel("メールアドレス").fill("test@example.com");
+   await page.getByLabel("パスワード").fill("password");
+   await page.getByRole("button", { name: "ログイン" }).click();
+   await page.waitForURL(/posts/);
+ 
+   await page.context().storageState({ path: authFile });
+ });

playwright.config.ts

playwright.config.tsのprojectsにsetupプロジェクトの追加と、各ブラウザに認証後のストレージ状態を読み込むように設定します。

playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  /* [...] */
  projects: [
+   { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
+       storageState: "playwright/.auth/user.json",
      },
+     dependencies: ["setup"],
    },

    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
+       storageState: "playwright/.auth/user.json",
      },
+     dependencies: ["setup"],
    },

    {
      name: "webkit",
      use: {
        ...devices["Desktop Safari"],
+       storageState: "playwright/.auth/user.json",
      },
+     dependencies: ["setup"],
    },
    /* [...] */
});

テスト作成

一覧→新規登録→更新→削除のCRUD処理をテストしてみます。

post.spec.ts
import { test, expect } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  await page.goto("http://localhost:3000/posts");
});

test("登録処理", async ({ page }) => {
  /* テストデータ */
  const titleCreateValue = "テストタイトル登録";
  const textCreateValue = "テストテキスト登録";

  /* 登録実行 */
  await page.getByRole("button", { name: "新規作成" }).click();
  await page.waitForURL(/posts\/new/);
  await page.getByLabel("タイトル").fill(titleCreateValue);
  await page.getByLabel("テキスト").fill(textCreateValue);
  await page.getByRole("button", { name: "登録" }).click();
  await page.waitForURL(/posts/);

  /* 登録したデータが一覧に表示されていることを確認 */
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(1)")
  ).toHaveText(titleCreateValue);
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(2)")
  ).toHaveText(textCreateValue);
});

test("更新処理", async ({ page }) => {
  /* テストデータ */
  const titleUpdateValue = "テストタイトル更新";
  const textUpdateValue = "テストテキスト更新";

  /* 更新実行 */
  // 1行目の編集ボタンをクリック
  await page.click(
    "table > tbody > tr:nth-of-type(1) td button:nth-of-type(1)"
  );
  await page.waitForURL(/posts\/.*/);
  await page.getByLabel("タイトル").fill(titleUpdateValue);
  await page.getByLabel("テキスト").fill(textUpdateValue);
  await page.getByRole("button", { name: "更新" }).click();
  await page.waitForURL(/posts/);

  /* 更新したデータが一覧に表示されていることを確認 */
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(1)")
  ).toHaveText(titleUpdateValue);
  await expect(
    page.locator("table > tbody > tr:nth-of-type(1) > td:nth-of-type(2)")
  ).toHaveText(textUpdateValue);
});

test("削除処理", async ({ page }) => {
  /* 削除実行 */
  const firstDeleteButtonElement =
    "table > tbody > tr:nth-of-type(1) td button:nth-of-type(2)";

  // 削除ボタン後の確認ダイアログでOKをクリックするように事前に設定
  page.on("dialog", (dialog) => void dialog.accept());

  // 1行目の削除ボタンをクリック
  await page.click(firstDeleteButtonElement);

  // テーブルを最新の状態にするため更新ボタンをクリック
  await page.click("main svg.tabler-icon.tabler-icon-refresh");

  /* 削除したデータが一覧に表示されていないことを確認 */
  await expect(page.locator("table > tbody > tr > td > div")).toHaveText(
    "表示するレコードがありません"
  );
});

テスト実行

Playwrightを実行(テストデータに依存関係があるため、並列化せず、workerは1つのみとします)

$ npx playwright test --workers=1 --headed

ブラウザで動作確認すると分かると思いますが、認証処理は1度のみで、その後のテストが実行されているのが分かると思います。

ちなみにuser.jsonにはどのように保存されているか確認してみたいと思います。
その前にブラウザでCookieとLocalStorageがどうなっているか確認してみましょう。

Cookieにnext-auth.csrf-token next-auth.callback-url next-auth.session-token、LocalStorageにnextauth.messageが保存されているのが分かります。
Cookieにnext-auth.csrf-token next-auth.callback-url next-auth.session-token、LocalStorageにnextauth.messageが保存されていますね。

user.json
{
  "cookies": [
    {
      "name": "next-auth.csrf-token",
      "value": "c006d32d9ad0fd5839403fc51daf2d0e9342058f9050f53140a46af5549cd0e6%7C99ed50907aa76e1bc866f39df7ab1da971dd9f6b59590c0c73be9bf1fc65fe91",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    },
    {
      "name": "next-auth.callback-url",
      "value": "http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fsignin%3FcallbackUrl%3Dhttp%253A%252F%252Flocalhost%253A3000%252F",
      "domain": "localhost",
      "path": "/",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    },
    {
      "name": "next-auth.session-token",
      "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..FbGb76XxU0GC52Fh.btJfStrZRShmCH1D1Ppwgu1-yrvJ4SWcL7Z7n5_163YK54ZvLDDf0bmZ99PiXWwoOkp3e2_Tv73GfzYzN25vl6_qaBNu1O2ceCCvPaqaZdr4t1sSUit5hLDVvbrtdrINq3TJ_Ff70w8ddvzNJNxpls6LMUWBiwGf-W7BuYiDSB5gimcFmBsTGkkXSEsWZsQQsZwyA4Sc6ujV-g5wywclOM0qk-q3i5-PnEFNdRNsKWIcVEIg8bGETJKbTA.DIZbPaQkSN0TbQU_3es9gw",
      "domain": "localhost",
      "path": "/",
      "expires": 1698459848.074628,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": [
    {
      "origin": "http://localhost:3000",
      "localStorage": [
        {
          "name": "nextauth.message",
          "value": "{\"event\":\"session\",\"data\":{\"trigger\":\"getSession\"},\"timestamp\":1695867846}"
        }
      ]
    }
  ]
}

user.jsonにもブラウザのコンソールと同じ値が保存されていますね。この値を元にPlaywrightがログイン処理をしているようです。

Discussion