Playwright で Clerk をテストするガイド【Next.js】
はじめに
先日、Next.js の勉強会で、Clerk による認証サービスの活用を取り上げました 🔐
認証は、Web アプリケーション開発において、特に重要な要素の一つでありながら、
テストが複雑な部分でもあります。
とはいえ、複数のページにまたがる認証フローは、手動テストだけでは不十分になりがちです。
今回は、Clerk の認証機能を Playwright でテストする方法について調査したので、基礎的な内容をまとめました!
時間の節約になれば、嬉しいです 🙌
Clerk とは?
もし、Clerk 自体の概要を知りたい方は、上記をご覧ください!
以前別記事にて、紹介しています 👍
Clerk のミドルウェアによる認証制限とは?
Clerk は、Next.js アプリケーションで簡単に認証機能を実装できる認証サービスです。
Clerk の特徴の一つは、
ミドルウェアを使用して特定のルートを保護できる点にあります。
そもそも、Next.js のミドルウェアは、リクエストがルートハンドラやページにたどり着く前に実行される関数です。
この仕組みを活用して、Clerk はユーザーの認証状態に基づいてアクセス制限を適用します!
基本的なミドルウェアの実装例は、以下のようになります:
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
// 保護するルートのパターンを定義
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/profile(.*)"]);
export default clerkMiddleware(async (auth, req) => {
// 保護されたルートの場合は認証を要求
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: [
// Next.js 内部ファイルと静的ファイルを除外
"/((?!_next|[^?]*\\.(html?|css|js|jpg|webp|png|svg|ttf|woff2?|ico)).*)",
// API ルートは常に実行
"/(api|trpc)(.*)",
],
};
この実装により、/dashboard
や /profile
で始まるすべてのルートには、
認証済みユーザーのみがアクセスでき、未認証ユーザーはサインインページにリダイレクトされます 👍
Clerk ミドルウェアを使用したアクセス制限のテスト方法について
Clerk ミドルウェアによる、アクセス制限が正しく機能しているかを検証するためには、
E2E(エンド・ツー・エンド)テストが効果的です。
特に、Playwright では、複数のブラウザでの自動テストを可能にする強力なツールで、ユーザー体験を模倣したテストを記述できます。
認証フローのような複雑なシナリオもテストできる点が大きな特徴です!
Clerk と Playwright の組み合わせ
Clerk は、Playwright と連携するための専用のテストパッケージを提供してくれているので、
このパッケージを使うことで、認証に関するテストが格段に簡単になります!!
npm install -D @clerk/testing playwright @playwright/test
Clerk と Playwright を組み合わせる際の主なポイントは、以下の通りです:
- テスト環境の設定:テスト専用の Clerk アカウントと API キーの設定
- 認証状態の管理:テスト間で認証状態を再利用可能にする
- 保護されたルートのテスト:認証済み/未認証状態での動作検証
- リダイレクトの検証:未認証時のリダイレクト動作の確認
Next.js での行う手順
それでは、実際に Next.js プロジェクトで Clerk 認証をテストする方法について、順を追って説明します。
1. テスト環境の設定
まず、テスト実行に必要な環境変数を設定します。
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_XXXXX
CLERK_SECRET_KEY=sk_test_XXXXX
E2E_CLERK_USER_USERNAME=testuser
E2E_CLERK_USER_PASSWORD=testpassword
これらの値は Clerk のダッシュボードから取得でき、テスト用のユーザーアカウントを作成する必要があります。
2. 認証状態を保存するディレクトリの作成
Playwright ではテスト間で認証状態を共有できます。そのためのディレクトリを作成します。
mkdir -p playwright/.clerk
echo $'\nplaywright/.clerk' >> .gitignore
3. グローバルセットアップの作成
テスト実行前に認証を行い、その状態を保存するグローバルセットアップを作成します。
// playwright/global.setup.ts
import { clerk, clerkSetup } from "@clerk/testing/playwright";
import { test as setup } from "@playwright/test";
import path from "path";
// Playwright の設定
setup("global setup", async ({}) => {
await clerkSetup();
});
// 認証ファイルのパス
const authFile = path.join(__dirname, "../playwright/.clerk/user.json");
// 認証してストレージに状態を保存
setup("authenticate and save state to storage", async ({ page }) => {
// 未認証ページに移動
await page.goto("/");
// Clerk の sign in ヘルパーを使用して認証
await clerk.signIn({
page,
signInParams: {
strategy: "password",
identifier: process.env.E2E_CLERK_USER_USERNAME!,
password: process.env.E2E_CLERK_USER_PASSWORD!,
},
});
// 保護されたページにアクセスできることを確認
await page.goto("/dashboard");
await page.waitForSelector("h1:has-text('Dashboard')");
// 認証状態を保存
await page.context().storageState({ path: authFile });
});
4. Playwright の設定
playwright.config.ts
を設定して、認証済み状態と未認証状態のテストを分けて実行できるようにします。
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
// グローバルセットアップ
{
name: "global setup",
testMatch: /global\.setup\.ts/,
},
// 未認証状態のテスト
{
name: "未認証テスト",
testMatch: /.*public\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
},
},
// 認証済み状態のテスト
{
name: "認証済みテスト",
testMatch: /.*authenticated\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
// 保存した認証状態を使用
storageState: "playwright/.clerk/user.json",
},
dependencies: ["global setup"],
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
これにより、テストは以下の流れで実行されます:
- グローバルセットアップが実行され、認証状態が保存される
- 未認証テストが実行される(通常のブラウザコンテキスト)
- 認証済みテストが実行される(保存された認証状態を使用)
5. テストコードの作成
次に、実際のテストケースを作成します。まずは認証済み状態でのテストです。
// tests/authenticated.spec.ts
import { test, expect } from "@playwright/test";
test.describe("認証済みユーザーテスト", () => {
test("ダッシュボードページにアクセスできる", async ({ page }) => {
await page.goto("/dashboard");
// ダッシュボードページが表示されていることを確認
await expect(page.locator("h1")).toContainText("Dashboard");
});
test("プロフィールページにアクセスできる", async ({ page }) => {
await page.goto("/profile");
// プロフィールページが表示されていることを確認
await expect(page.locator("h1")).toContainText("Profile");
});
});
次に、未認証状態でのテストを作成します。
// tests/public.spec.ts
import { test, expect } from "@playwright/test";
test.describe("未認証ユーザーテスト", () => {
test("保護されたページにアクセスするとリダイレクトされる", async ({
page,
}) => {
// ダッシュボードページにアクセス試行
await page.goto("/dashboard");
// サインインページにリダイレクトされることを確認
await expect(page).toHaveURL(/.*sign-in.*/);
});
test("パブリックページにはアクセスできる", async ({ page }) => {
await page.goto("/");
// ホームページが表示されていることを確認
await expect(page.locator("h1")).toContainText("Home");
});
});
6. 特定のテストのみで認証状態を使用する方法
全てのテストで認証状態を共有するのではなく、特定のテストケースのみで認証が必要な場合は、setupClerkTestingToken
ヘルパー関数を使用できます。
// tests/specific-auth.spec.ts
import { setupClerkTestingToken } from "@clerk/testing/playwright";
import { test, expect } from "@playwright/test";
test("特定のテストで認証する", async ({ page }) => {
// テスト用トークンを設定
await setupClerkTestingToken({ page });
// サインアップフローなどのテスト
await page.goto("/sign-up");
// テストロジックを追加
});
7. テストの実行
設定したテストを実行するには、以下のコマンドを実行します:
npx playwright test
CI 環境での実行や、特定のテストのみを実行する場合は以下のようなコマンドを使用できます:
# 特定のテストファイルのみ実行
npx playwright test tests/authenticated.spec.ts
# デバッグモードで実行(ブラウザが表示される)
npx playwright test --debug
まとめ
Clerk と Playwright を組み合わせることで、認証済みユーザーのみの閲覧可能なページなどの、
Next.js における、アクセス制限を、自動でテストできます!
これにより、
- アプリケーション(ページ数)が複雑化
- 柔軟なアクセス制限が求められるプロダクト
- 高いセキュリティ要件を持つプロダクト
上記のようなアプリでは、
継続的に自動でテストを実行できると、嬉しいですよね!
(毎回、手動で全ページに適切な制限が適用されているか、チェックするのは、大変です 😎)
おわりに
最後まで読んでいただき、ありがとうございます 🥳
下記の、React/Next.js ハンズオン勉強会での、振り返りのような記事ですが、
認証周りの E2E テスト実装として、当記事が参考になれば幸いです!
そして、もし、間違いや補足情報などがありましたら、
ぜひコメントを追加してください!
Happy Hacking :)
参考
Discussion