# 身份验证流程配方 > **适用场景**:你需要测试登录、注册、退出、会话管理或任何与身份验证相关的用户流程。 --- ## 配方 1:基本登录 ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("使用有效凭据登录", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() // 等待登录完成后页面导航 await expect(page).toHaveURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() await expect(page.getByText("Welcome, user@example.com")).toBeVisible() }) test("无效凭据时显示错误", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("WrongPassword") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByRole("alert")).toContainText("Invalid email or password") // 应停留在登录页面 await expect(page).toHaveURL("/login") }) test("空字段时显示验证错误", async ({ page }) => { await page.goto("/login") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByText("Email is required")).toBeVisible() await expect(page.getByText("Password is required")).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("使用有效凭据登录", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() await expect(page.getByText("Welcome, user@example.com")).toBeVisible() }) test("无效凭据时显示错误", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("WrongPassword") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByRole("alert")).toContainText("Invalid email or password") await expect(page).toHaveURL("/login") }) test("空字段时显示验证错误", async ({ page }) => { await page.goto("/login") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByText("Email is required")).toBeVisible() await expect(page.getByText("Password is required")).toBeVisible() }) ``` --- ## 配方 2:带「记住我」的登录 ### 完整示例 **TypeScript** ```typescript import { test, expect, type BrowserContext } from "@playwright/test" test("记住我功能可在浏览器重启后保持会话", async ({ browser }) => { // 第一次会话:勾选「记住我」后登录 const context1 = await browser.newContext() const page1 = await context1.newPage() await page1.goto("/login") await page1.getByLabel("Email").fill("user@example.com") await page1.getByLabel("Password").fill("SecurePass123!") await page1.getByLabel("Remember me").check() await page1.getByRole("button", { name: "Sign in" }).click() await expect(page1).toHaveURL("/dashboard") // 保存存储状态(cookies + localStorage) const storageState = await context1.storageState() await context1.close() // 第二次会话:恢复状态以模拟浏览器重启 const context2 = await browser.newContext({ storageState }) const page2 = await context2.newPage() await page2.goto("/dashboard") // 应仍处于登录状态,无需重新认证 await expect(page2).toHaveURL("/dashboard") await expect(page2.getByText("Welcome, user@example.com")).toBeVisible() await context2.close() }) test("不勾选记住我时不会保持会话", async ({ browser }) => { const context1 = await browser.newContext() const page1 = await context1.newPage() await page1.goto("/login") await page1.getByLabel("Email").fill("user@example.com") await page1.getByLabel("Password").fill("SecurePass123!") // 明确不勾选「记住我」 await expect(page1.getByLabel("Remember me")).not.toBeChecked() await page1.getByRole("button", { name: "Sign in" }).click() await expect(page1).toHaveURL("/dashboard") // 仅捕获 cookies(不包括 sessionStorage),以模拟新标签页/窗口 const cookies = await context1.cookies() await context1.close() // 使用仅含持久化 cookies(排除会话 cookies)的新上下文 const persistentCookies = cookies.filter((c) => c.expires > 0) const context2 = await browser.newContext() await context2.addCookies(persistentCookies) const page2 = await context2.newPage() await page2.goto("/dashboard") // 由于会话未被持久化,应重定向到登录页面 await expect(page2).toHaveURL(/\/login/) await context2.close() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("记住我功能可在浏览器重启后保持会话", async ({ browser }) => { const context1 = await browser.newContext() const page1 = await context1.newPage() await page1.goto("/login") await page1.getByLabel("Email").fill("user@example.com") await page1.getByLabel("Password").fill("SecurePass123!") await page1.getByLabel("Remember me").check() await page1.getByRole("button", { name: "Sign in" }).click() await expect(page1).toHaveURL("/dashboard") const storageState = await context1.storageState() await context1.close() const context2 = await browser.newContext({ storageState }) const page2 = await context2.newPage() await page2.goto("/dashboard") await expect(page2).toHaveURL("/dashboard") await expect(page2.getByText("Welcome, user@example.com")).toBeVisible() await context2.close() }) ``` --- ## 配方 3:带邮箱验证的注册(模拟方式) ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("通过模拟邮箱验证完成注册流程", async ({ page }) => { // 拦截验证邮件 API,以便捕获 token let verificationToken = "" await page.route("**/api/auth/send-verification", async (route) => { // 让请求发送到服务器 const response = await route.fetch() const body = await response.json() // 从 API 响应中捕获 token(测试环境会暴露此值) verificationToken = body.verificationToken await route.fulfill({ response }) }) // 步骤 1:填写注册表单 await page.goto("/signup") await page.getByLabel("Full name").fill("Jane Tester") await page.getByLabel("Email").fill("jane@example.com") await page.getByLabel("Password", { exact: true }).fill("SecurePass123!") await page.getByLabel("Confirm password").fill("SecurePass123!") await page.getByLabel("I agree to the Terms of Service").check() await page.getByRole("button", { name: "Create account" }).click() // 步骤 2:验证显示「请查收邮件」页面 await expect(page.getByText("Check your email")).toBeVisible() await expect(page.getByText("jane@example.com")).toBeVisible() // 步骤 3:使用捕获的 token 直接导航至验证 URL expect(verificationToken).toBeTruthy() await page.goto(`/verify-email?token=${verificationToken}`) // 步骤 4:验证账户已激活 await expect(page.getByText("Email verified successfully")).toBeVisible() await expect(page).toHaveURL("/login") // 步骤 5:使用新账户登录 await page.getByLabel("Email").fill("jane@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") await expect(page.getByText("Welcome, Jane Tester")).toBeVisible() }) test("使用完全模拟的邮件 API 注册(无服务器依赖)", async ({ page }) => { const fakeToken = "test-verification-token-12345" // 模拟注册 API 端点 await page.route("**/api/auth/signup", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "Verification email sent", verificationToken: fakeToken, }), }) }) // 模拟验证端点 await page.route(`**/api/auth/verify-email?token=${fakeToken}`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "Email verified", redirectTo: "/login" }), }) }) await page.goto("/signup") await page.getByLabel("Full name").fill("Jane Tester") await page.getByLabel("Email").fill("jane@example.com") await page.getByLabel("Password", { exact: true }).fill("SecurePass123!") await page.getByLabel("Confirm password").fill("SecurePass123!") await page.getByLabel("I agree to the Terms of Service").check() await page.getByRole("button", { name: "Create account" }).click() await expect(page.getByText("Check your email")).toBeVisible() // 模拟点击邮件中的验证链接 await page.goto(`/verify-email?token=${fakeToken}`) await expect(page.getByText("Email verified successfully")).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("通过模拟邮箱验证完成注册流程", async ({ page }) => { let verificationToken = "" await page.route("**/api/auth/send-verification", async (route) => { const response = await route.fetch() const body = await response.json() verificationToken = body.verificationToken await route.fulfill({ response }) }) await page.goto("/signup") await page.getByLabel("Full name").fill("Jane Tester") await page.getByLabel("Email").fill("jane@example.com") await page.getByLabel("Password", { exact: true }).fill("SecurePass123!") await page.getByLabel("Confirm password").fill("SecurePass123!") await page.getByLabel("I agree to the Terms of Service").check() await page.getByRole("button", { name: "Create account" }).click() await expect(page.getByText("Check your email")).toBeVisible() expect(verificationToken).toBeTruthy() await page.goto(`/verify-email?token=${verificationToken}`) await expect(page.getByText("Email verified successfully")).toBeVisible() await expect(page).toHaveURL("/login") }) ``` --- ## 配方 4:密码重置流程 ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("完成密码重置流程", async ({ page }) => { let resetToken = "" // 拦截密码重置 API 以捕获重置 token await page.route("**/api/auth/forgot-password", async (route) => { const response = await route.fetch() const body = await response.json() resetToken = body.resetToken await route.fulfill({ response }) }) // 步骤 1:请求密码重置 await page.goto("/forgot-password") await page.getByLabel("Email").fill("user@example.com") await page.getByRole("button", { name: "Send reset link" }).click() await expect(page.getByText("Reset link sent")).toBeVisible() await expect(page.getByText("user@example.com")).toBeVisible() // 步骤 2:使用 token 导航至重置页面 expect(resetToken).toBeTruthy() await page.goto(`/reset-password?token=${resetToken}`) // 步骤 3:设置新密码 await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!") await page.getByLabel("Confirm new password").fill("NewSecurePass456!") await page.getByRole("button", { name: "Reset password" }).click() await expect(page.getByText("Password reset successfully")).toBeVisible() // 步骤 4:使用新密码登录 await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("NewSecurePass456!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") }) test("密码重置 token 过期时显示错误", async ({ page }) => { await page.goto("/reset-password?token=expired-token-123") await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!") await page.getByLabel("Confirm new password").fill("NewSecurePass456!") await page.getByRole("button", { name: "Reset password" }).click() await expect(page.getByRole("alert")).toContainText(/token has expired|link is no longer valid/i) await expect(page.getByRole("link", { name: "Request a new reset link" })).toBeVisible() }) test("密码重置强制密码强度要求", async ({ page }) => { await page.goto("/reset-password?token=valid-token") // 尝试弱密码 await page.getByLabel("New password", { exact: true }).fill("123") await page.getByLabel("Confirm new password").fill("123") await page.getByRole("button", { name: "Reset password" }).click() await expect(page.getByText(/at least 8 characters/i)).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("完成密码重置流程", async ({ page }) => { let resetToken = "" await page.route("**/api/auth/forgot-password", async (route) => { const response = await route.fetch() const body = await response.json() resetToken = body.resetToken await route.fulfill({ response }) }) await page.goto("/forgot-password") await page.getByLabel("Email").fill("user@example.com") await page.getByRole("button", { name: "Send reset link" }).click() await expect(page.getByText("Reset link sent")).toBeVisible() expect(resetToken).toBeTruthy() await page.goto(`/reset-password?token=${resetToken}`) await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!") await page.getByLabel("Confirm new password").fill("NewSecurePass456!") await page.getByRole("button", { name: "Reset password" }).click() await expect(page.getByText("Password reset successfully")).toBeVisible() await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("NewSecurePass456!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") }) ``` --- ## 配方 5:OAuth 登录(模拟回调) ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("通过模拟的 Google OAuth 回调登录", async ({ page }) => { // 拦截指向 Google 的 OAuth 重定向并模拟回调 await page.route("**/accounts.google.com/**", async (route) => { // 不实际跳转到 Google,而是重定向回我们的回调 URL // 并携带一个模拟的授权码 const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000") callbackUrl.searchParams.set("code", "mock-auth-code-12345") callbackUrl.searchParams.set( "state", route .request() .url() .match(/state=([^&]+)/)?.[1] || "" ) await route.fulfill({ status: 302, headers: { location: callbackUrl.toString() }, }) }) // 模拟后端 token 交换 await page.route("**/api/auth/callback/google**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user: { id: "1", email: "googleuser@gmail.com", name: "Google User", avatar: "https://example.com/avatar.jpg", }, token: "mock-jwt-token", }), }) }) await page.goto("/login") // 点击 OAuth 按钮 await page.getByRole("button", { name: /Sign in with Google/i }).click() // 模拟的 OAuth 流程完成后,应跳转到仪表盘 await expect(page).toHaveURL("/dashboard") await expect(page.getByText("Google User")).toBeVisible() }) test("优雅处理 OAuth 登录失败", async ({ page }) => { // 模拟 OAuth 提供商返回错误 await page.route("**/accounts.google.com/**", async (route) => { const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000") callbackUrl.searchParams.set("error", "access_denied") callbackUrl.searchParams.set("error_description", "User denied access") await route.fulfill({ status: 302, headers: { location: callbackUrl.toString() }, }) }) await page.goto("/login") await page.getByRole("button", { name: /Sign in with Google/i }).click() await expect(page.getByRole("alert")).toContainText(/authentication failed|access denied/i) await expect(page).toHaveURL(/\/login/) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("通过模拟的 Google OAuth 回调登录", async ({ page }) => { await page.route("**/accounts.google.com/**", async (route) => { const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000") callbackUrl.searchParams.set("code", "mock-auth-code-12345") callbackUrl.searchParams.set( "state", route .request() .url() .match(/state=([^&]+)/)?.[1] || "" ) await route.fulfill({ status: 302, headers: { location: callbackUrl.toString() }, }) }) await page.route("**/api/auth/callback/google**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user: { id: "1", email: "googleuser@gmail.com", name: "Google User" }, token: "mock-jwt-token", }), }) }) await page.goto("/login") await page.getByRole("button", { name: /Sign in with Google/i }).click() await expect(page).toHaveURL("/dashboard") await expect(page.getByText("Google User")).toBeVisible() }) ``` --- ## 配方 6:基于角色的访问测试 ### 完整示例 **TypeScript** ```typescript import { test, expect, type Page } from "@playwright/test" import path from "path" // 将每个角色的认证状态存储到单独的文件中 const authDir = path.resolve(__dirname, "../.auth") // 设置:为每个角色创建认证状态文件(在 globalSetup 或 auth.setup 中运行) // 此模式利用 Playwright 的 storageState 实现高效的角色切换 const roles = ["admin", "editor", "viewer"] as const type Role = (typeof roles)[number] function authFile(role: Role): string { return path.join(authDir, `${role}.json`) } // 认证设置项目——在所有测试之前运行 test.describe("auth setup", () => { const credentials: Record = { admin: { email: "admin@example.com", password: "AdminPass123!" }, editor: { email: "editor@example.com", password: "EditorPass123!" }, viewer: { email: "viewer@example.com", password: "ViewerPass123!" }, } for (const role of roles) { test(`authenticate as ${role}`, async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill(credentials[role].email) await page.getByLabel("Password").fill(credentials[role].password) await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") await page.context().storageState({ path: authFile(role) }) }) } }) // 管理员测试 test.describe("admin access", () => { test.use({ storageState: authFile("admin") }) test("管理员可以访问用户管理", async ({ page }) => { await page.goto("/admin/users") await expect(page).toHaveURL("/admin/users") await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible() await expect(page.getByRole("button", { name: "Add user" })).toBeVisible() await expect(page.getByRole("button", { name: "Delete" }).first()).toBeVisible() }) test("管理员可以访问系统设置", async ({ page }) => { await page.goto("/admin/settings") await expect(page).toHaveURL("/admin/settings") await expect(page.getByRole("heading", { name: "System Settings" })).toBeVisible() }) }) // 编辑者测试 test.describe("editor access", () => { test.use({ storageState: authFile("editor") }) test("编辑者可以创建和编辑内容", async ({ page }) => { await page.goto("/content") await expect(page.getByRole("button", { name: "New post" })).toBeVisible() await expect(page.getByRole("button", { name: "Edit" }).first()).toBeVisible() }) test("编辑者无法访问管理区域", async ({ page }) => { await page.goto("/admin/users") // 应被重定向或显示禁止访问 await expect(page).toHaveURL(/\/(403|dashboard|login)/) }) }) // 查看者测试 test.describe("viewer access", () => { test.use({ storageState: authFile("viewer") }) test("查看者可以阅读内容", async ({ page }) => { await page.goto("/content") await expect(page.getByRole("article").first()).toBeVisible() }) test("查看者无法创建内容", async ({ page }) => { await page.goto("/content") await expect(page.getByRole("button", { name: "New post" })).not.toBeVisible() }) test("查看者无法访问管理区域", async ({ page }) => { await page.goto("/admin/users") await expect(page).toHaveURL(/\/(403|dashboard|login)/) }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const path = require("path") const authDir = path.resolve(__dirname, "../.auth") function authFile(role) { return path.join(authDir, `${role}.json`) } // 管理员测试 test.describe("admin access", () => { test.use({ storageState: authFile("admin") }) test("管理员可以访问用户管理", async ({ page }) => { await page.goto("/admin/users") await expect(page).toHaveURL("/admin/users") await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible() await expect(page.getByRole("button", { name: "Add user" })).toBeVisible() }) test("管理员可以访问系统设置", async ({ page }) => { await page.goto("/admin/settings") await expect(page).toHaveURL("/admin/settings") }) }) // 查看者测试 test.describe("viewer access", () => { test.use({ storageState: authFile("viewer") }) test("查看者无法创建内容", async ({ page }) => { await page.goto("/content") await expect(page.getByRole("button", { name: "New post" })).not.toBeVisible() }) test("查看者无法访问管理区域", async ({ page }) => { await page.goto("/admin/users") await expect(page).toHaveURL(/\/(403|dashboard|login)/) }) }) ``` --- ## 配方 7:会话超时处理 ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("会话超时后重定向到登录页", async ({ page, context }) => { // 先登录 await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") // 清除会话 cookie 以模拟会话过期 const cookies = await context.cookies() const sessionCookie = cookies.find( (c) => c.name === "session" || c.name === "sid" || c.name === "connect.sid" ) if (sessionCookie) { await context.clearCookies({ name: sessionCookie.name }) } // 尝试导航至受保护页面 await page.goto("/settings") // 应重定向到登录页并携带返回 URL await expect(page).toHaveURL(/\/login/) await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible() }) test("超时前显示会话到期警告", async ({ page }) => { // 模拟会话检查端点返回「即将到期」 await page.route("**/api/auth/session", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ valid: true, expiresIn: 120, // 剩余 2 分钟 }), }) }) await page.goto("/dashboard") // 应用应在会话即将到期时显示警告 await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 }) await expect(page.getByRole("button", { name: /extend|stay logged in/i })).toBeVisible() }) test("用户点击续期时延长会话", async ({ page }) => { let sessionExtended = false await page.route("**/api/auth/session", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ valid: true, expiresIn: 120 }), }) }) await page.route("**/api/auth/refresh", async (route) => { sessionExtended = true await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ valid: true, expiresIn: 3600 }), }) }) await page.goto("/dashboard") await expect(page.getByRole("button", { name: /extend|stay logged in/i })).toBeVisible({ timeout: 10000, }) await page.getByRole("button", { name: /extend|stay logged in/i }).click() expect(sessionExtended).toBe(true) await expect(page.getByText(/session.*expir/i)).not.toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("会话超时后重定向到登录页", async ({ page, context }) => { await page.goto("/login") await page.getByLabel("Email").fill("user@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() await expect(page).toHaveURL("/dashboard") const cookies = await context.cookies() const sessionCookie = cookies.find( (c) => c.name === "session" || c.name === "sid" || c.name === "connect.sid" ) if (sessionCookie) { await context.clearCookies({ name: sessionCookie.name }) } await page.goto("/settings") await expect(page).toHaveURL(/\/login/) await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible() }) ``` --- ## 配方 8:退出登录 ### 完整示例 **TypeScript** ```typescript import { test, expect } from "@playwright/test" // 使用已登录状态 test.use({ storageState: ".auth/user.json" }) test("退出登录并重定向到登录页", async ({ page }) => { await page.goto("/dashboard") await expect(page.getByText("Welcome")).toBeVisible() // 打开用户菜单并点击退出 await page.getByRole("button", { name: /user menu|account|profile/i }).click() await page.getByRole("menuitem", { name: "Log out" }).click() // 应重定向到登录页 await expect(page).toHaveURL("/login") // 验证无法再访问受保护页面 await page.goto("/dashboard") await expect(page).toHaveURL(/\/login/) }) test("退出登录清除所有会话数据", async ({ page, context }) => { await page.goto("/dashboard") await page.getByRole("button", { name: /user menu|account|profile/i }).click() await page.getByRole("menuitem", { name: "Log out" }).click() await expect(page).toHaveURL("/login") // 验证 cookies 已被清除 const cookies = await context.cookies() const sessionCookies = cookies.filter( (c) => c.name === "session" || c.name === "sid" || c.name === "token" ) expect(sessionCookies).toHaveLength(0) // 验证 localStorage 已被清除 const token = await page.evaluate(() => localStorage.getItem("authToken")) expect(token).toBeNull() }) test("从所有设备退出登录", async ({ page }) => { let logoutAllCalled = false await page.route("**/api/auth/logout-all", async (route) => { logoutAllCalled = true await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "Logged out from all devices" }), }) }) await page.goto("/settings/security") await page.getByRole("button", { name: "Log out of all devices" }).click() // 在对话框中确认操作 await page.getByRole("dialog").getByRole("button", { name: "Confirm" }).click() expect(logoutAllCalled).toBe(true) await expect(page).toHaveURL(/\/login/) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test.use({ storageState: ".auth/user.json" }) test("退出登录并重定向到登录页", async ({ page }) => { await page.goto("/dashboard") await page.getByRole("button", { name: /user menu|account|profile/i }).click() await page.getByRole("menuitem", { name: "Log out" }).click() await expect(page).toHaveURL("/login") await page.goto("/dashboard") await expect(page).toHaveURL(/\/login/) }) test("退出登录清除所有会话数据", async ({ page, context }) => { await page.goto("/dashboard") await page.getByRole("button", { name: /user menu|account|profile/i }).click() await page.getByRole("menuitem", { name: "Log out" }).click() await expect(page).toHaveURL("/login") const cookies = await context.cookies() const sessionCookies = cookies.filter( (c) => c.name === "session" || c.name === "sid" || c.name === "token" ) expect(sessionCookies).toHaveLength(0) }) ``` --- ## 变体 ### 通过 API 登录以提升速度 测试非认证功能时跳过 UI 登录。使用 `request` 上下文更快地获取 token。 **TypeScript** ```typescript import { test as setup } from "@playwright/test" setup("通过 API 进行认证", async ({ request }) => { const response = await request.post("/api/auth/login", { data: { email: "user@example.com", password: "SecurePass123!", }, }) expect(response.ok()).toBeTruthy() // 保存认证状态 await request.storageState({ path: ".auth/user.json" }) }) ``` ### 多因素认证 ```typescript test("使用 MFA(TOTP)登录", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("mfa-user@example.com") await page.getByLabel("Password").fill("SecurePass123!") await page.getByRole("button", { name: "Sign in" }).click() // MFA 挑战页面出现 await expect(page.getByText("Enter verification code")).toBeVisible() // 在测试环境中,使用可预测的 TOTP 码或模拟验证 await page.route("**/api/auth/verify-mfa", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, token: "jwt-token" }), }) }) await page.getByLabel("Verification code").fill("123456") await page.getByRole("button", { name: "Verify" }).click() await expect(page).toHaveURL("/dashboard") }) ``` ### 使用不同浏览器上下文测试认证 ```typescript test("两个用户同时交互", async ({ browser }) => { const adminContext = await browser.newContext({ storageState: ".auth/admin.json", }) const userContext = await browser.newContext({ storageState: ".auth/viewer.json", }) const adminPage = await adminContext.newPage() const userPage = await userContext.newPage() // 管理员封禁某用户 await adminPage.goto("/admin/users") await adminPage .getByRole("row", { name: "viewer@example.com" }) .getByRole("button", { name: "Ban" }) .click() await adminPage.getByRole("dialog").getByRole("button", { name: "Confirm" }).click() // 用户尝试导航,应被阻止 await userPage.goto("/dashboard") await expect(userPage.getByText(/account.*banned|access.*revoked/i)).toBeVisible() await adminContext.close() await userContext.close() }) ``` --- ## 提示 1. **使用 `storageState` 实现可复用的认证**。在设置项目中运行一次登录,将状态保存到 JSON 文件,然后在所有测试中复用。这样只需为每个角色登录一次,大幅提升测试套件的运行速度。 2. **切勿在测试文件中硬编码凭据**。使用环境变量或通过 `dotenv` 加载的 `.env` 文件。在 `playwright.config.ts` 中设置 `process.env` 值,或使用 `use` 中的 `env` 选项。 3. **非认证测试优先使用基于 API 的登录**。仅在认证相关测试中测试登录 UI。其他所有情况,通过 API 或恢复 `storageState` 来完成认证。这样测试更快、更稳定。 4. **同时测试负面路径**。无效凭据、过期 token、账户被锁定、速率限制——这些是用户在生产环境中实际遇到的场景,却常常测试不足。 5. **在测试环境中设置更短的会话超时时间**。如果你的应用有 30 分钟的会话超时,在测试环境中将其配置为 30 秒,这样无需让测试变慢就能测试超时行为。 --- ## 相关 - [Playwright 认证文档](https://playwright.dev/docs/auth) - `patterns/page-objects.md`——将登录流程封装到页面对象中 - `foundations/assertions.md`——认证状态的断言模式 - `recipes/crud-testing.md`——认证后测试 CRUD 操作