Playwrightでテスト開始時に1度だけ認証処理をする
認証が必要なアプリケーションのE2EテストをPlaywrightでテストする時、どのように実装しているでしょうか?
beforeEach hook
を使って各テスト前に都度、認証の処理を実行させることができますが、この場合テスト数で多くなるにつれ、テスト時間がどんどん長くなってしまいます。
Playwrightには認証機能があり、テスト開始時に1度だけ認証の処理を走らせ、以降、認証された状態で各テストを実行することができます。
1度の認証で各テストを実行できる仕組みとしては、最初の認証処理時にJSONファイルでストレージ状態(CookieとLocalStorage)を管理し、各テストでブラウザが、そのJSONファイルを読み込むことでログイン状態にする、という感じです。
今回はPlaywrightの認証機能を実装して、実際にどのように動作しているか確認してみたいと思います。
前準備
テストはT3 Stackで私が以前構築した認証付き管理画面を使って試してみます。
アプリケーションの作成
$ 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に保存します。
+ 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プロジェクトの追加と、各ブラウザに認証後のストレージ状態を読み込むように設定します。
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処理をテストしてみます。
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
が保存されていますね。
{
"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