# 身份验证测试 > **使用场景**:任何包含登录、会话管理或受保护路由的应用。身份验证是测试套件最常见的速度瓶颈——处理好这部分,整个测试套件都会提速。 > **前置条件**:[core/configuration.md](configuration.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## 快速参考 ```typescript // Storage state 复用——快速身份验证的 #1 模式 await page.goto("/login") await page.getByLabel("Email").fill("user@test.com") await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() await page.context().storageState({ path: ".auth/user.json" }) // 在配置中复用——每个测试已登录,零登录开销 { use: { storageState: ".auth/user.json" } } // API 登录——完全跳过 UI const context = await browser.newContext() const response = await context.request.post("/api/auth/login", { data: { email: "user@test.com", password: "password" }, }) await context.storageState({ path: ".auth/user.json" }) ``` ## 模式 ### Storage State 复用 **适用场景**:需要已认证的测试,且希望避免在每个测试前都执行登录。这几乎是所有项目的默认模式。 **避免场景**:测试需要完全新鲜的会话(无任何之前的状态),或者你正在测试登录流程本身。 Playwright 的 `storageState` 将 cookies 和 localStorage 序列化为一个 JSON 文件。在任何浏览器上下文中加载它即可立即获得已认证状态。 **TypeScript** ```typescript // scripts/save-auth-state.ts — 运行一次以生成状态文件 import { chromium } from "@playwright/test" async function saveAuthState() { const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto("http://localhost:3000/login") await page.getByLabel("Email").fill("user@test.com") await page.getByLabel("Password").fill("s3cure!Pass") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 将 cookies + localStorage 保存到文件 await context.storageState({ path: ".auth/user.json" }) await browser.close() } saveAuthState() ``` ```typescript // playwright.config.ts — 为所有测试加载已保存的状态 import { defineConfig } from "@playwright/test" export default defineConfig({ use: { baseURL: "http://localhost:3000", storageState: ".auth/user.json", }, }) ``` ```typescript // tests/dashboard.spec.ts — 测试启动时已处于登录状态 import { test, expect } from "@playwright/test" test("authenticated user sees dashboard", async ({ page }) => { await page.goto("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() // 无需登录步骤——storageState 已处理 }) ``` **JavaScript** ```javascript // scripts/save-auth-state.js const { chromium } = require("@playwright/test") async function saveAuthState() { const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto("http://localhost:3000/login") await page.getByLabel("Email").fill("user@test.com") await page.getByLabel("Password").fill("s3cure!Pass") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") await context.storageState({ path: ".auth/user.json" }) await browser.close() } saveAuthState() ``` ```javascript // playwright.config.js const { defineConfig } = require("@playwright/test") module.exports = defineConfig({ use: { baseURL: "http://localhost:3000", storageState: ".auth/user.json", }, }) ``` ### 全局设置身份验证 **适用场景**:你希望在整个测试套件运行前只认证一次,然后在所有地方复用该会话。这是 Playwright 推荐的标准方法。 **避免场景**:不同的测试需要不同的用户,或者你的令牌过期速度比测试套件运行速度更快。 全局设置在每次 `npx playwright test` 调用时运行一次。它执行登录、保存状态,每个引用该状态文件的测试项目都会以已认证状态启动。 **TypeScript** ```typescript // global-setup.ts import { chromium, type FullConfig } from "@playwright/test" async function globalSetup(config: FullConfig) { const { baseURL } = config.projects[0].use const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto(`${baseURL}/login`) await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!) await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("**/dashboard") await context.storageState({ path: ".auth/user.json" }) await browser.close() } export default globalSetup ``` ```typescript // playwright.config.ts import { defineConfig } from "@playwright/test" export default defineConfig({ globalSetup: require.resolve("./global-setup"), use: { baseURL: "http://localhost:3000", storageState: ".auth/user.json", }, }) ``` **JavaScript** ```javascript // global-setup.js const { chromium } = require("@playwright/test") async function globalSetup(config) { const { baseURL } = config.projects[0].use const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto(`${baseURL}/login`) await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL) await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("**/dashboard") await context.storageState({ path: ".auth/user.json" }) await browser.close() } module.exports = globalSetup ``` ```javascript // playwright.config.js const { defineConfig } = require("@playwright/test") module.exports = defineConfig({ globalSetup: require.resolve("./global-setup"), use: { baseURL: "http://localhost:3000", storageState: ".auth/user.json", }, }) ``` **重要提示**:将 `.auth/` 添加到你的 `.gitignore` 中。Auth 状态文件包含会话令牌,绝不应提交到版本控制。 ### 每 Worker 身份验证 **适用场景**:每个并行 worker 需要自己的已认证会话,以避免竞态条件。当测试修改用户状态(个人资料更新、设置更改、数据变更)时,这是必需的。 **避免场景**:测试是只读的,共享会话是安全的,或者你只以串行方式运行测试。 Worker 作用域的 fixture 每个 worker 进程运行一次,而不是每个测试运行一次。这为每个并行 worker 提供了独立的隔离会话。 **TypeScript** ```typescript // fixtures/auth.ts import { test as base, type BrowserContext } from "@playwright/test" type AuthFixtures = { authenticatedContext: BrowserContext } export const test = base.extend<{}, AuthFixtures>({ authenticatedContext: [ async ({ browser }, use) => { const context = await browser.newContext() const page = await context.newPage() await page.goto("/login") await page.getByLabel("Email").fill(`worker-${test.info().parallelIndex}@test.com`) await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") await page.close() await use(context) await context.close() }, { scope: "worker" }, ], }) export { expect } from "@playwright/test" ``` ```typescript // tests/profile.spec.ts import { test, expect } from "../fixtures/auth" test("update profile name", async ({ authenticatedContext }) => { const page = await authenticatedContext.newPage() await page.goto("/settings/profile") await page.getByLabel("Display name").fill("Updated Name") await page.getByRole("button", { name: "Save" }).click() await expect(page.getByText("Profile updated")).toBeVisible() }) ``` **JavaScript** ```javascript // fixtures/auth.js const { test: base } = require("@playwright/test") const test = base.extend({ authenticatedContext: [ async ({ browser }, use) => { const context = await browser.newContext() const page = await context.newPage() await page.goto("/login") await page.getByLabel("Email").fill(`worker-${test.info().parallelIndex}@test.com`) await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") await page.close() await use(context) await context.close() }, { scope: "worker" }, ], }) module.exports = { test, expect: require("@playwright/test").expect } ``` ### 多角色 **适用场景**:你的应用有基于角色的访问控制,你需要测试管理员、普通用户和查看者看到不同的内容。 **避免场景**:你的应用只有一个用户角色。 使用不同的 Playwright 项目配合不同的 storage state 文件,每个角色一个。这比在测试内切换角色更清晰。 **TypeScript** ```typescript // global-setup.ts — 认证所有角色 import { chromium, type FullConfig } from "@playwright/test" const users = [ { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD! }, { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD! }, { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD! }, ] async function globalSetup(config: FullConfig) { const { baseURL } = config.projects[0].use for (const { role, email, password } of users) { const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto(`${baseURL}/login`) await page.getByLabel("Email").fill(email) await page.getByLabel("Password").fill(password) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("**/dashboard") await context.storageState({ path: `.auth/${role}.json` }) await browser.close() } } export default globalSetup ``` ```typescript // playwright.config.ts — 每个角色一个项目 import { defineConfig } from "@playwright/test" export default defineConfig({ globalSetup: require.resolve("./global-setup"), projects: [ { name: "admin", use: { storageState: ".auth/admin.json" }, testMatch: "**/*.admin.spec.ts", }, { name: "user", use: { storageState: ".auth/user.json" }, testMatch: "**/*.user.spec.ts", }, { name: "viewer", use: { storageState: ".auth/viewer.json" }, testMatch: "**/*.viewer.spec.ts", }, { name: "unauthenticated", use: { storageState: { cookies: [], origins: [] } }, testMatch: "**/*.anon.spec.ts", }, ], }) ``` ```typescript // tests/admin-panel.admin.spec.ts import { test, expect } from "@playwright/test" test("admin can access user management", async ({ page }) => { await page.goto("/admin/users") await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible() await expect(page.getByRole("button", { name: "Delete user" })).toBeEnabled() }) ``` ```typescript // tests/admin-panel.viewer.spec.ts import { test, expect } from "@playwright/test" test("viewer cannot access admin panel", async ({ page }) => { await page.goto("/admin/users") // 应重定向到禁止访问或仪表盘 await expect(page.getByText("Access denied")).toBeVisible() }) ``` **JavaScript** ```javascript // global-setup.js const { chromium } = require("@playwright/test") const users = [ { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD }, { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD }, { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD }, ] async function globalSetup(config) { const { baseURL } = config.projects[0].use for (const { role, email, password } of users) { const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() await page.goto(`${baseURL}/login`) await page.getByLabel("Email").fill(email) await page.getByLabel("Password").fill(password) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("**/dashboard") await context.storageState({ path: `.auth/${role}.json` }) await browser.close() } } module.exports = globalSetup ``` ```javascript // playwright.config.js const { defineConfig } = require("@playwright/test") module.exports = defineConfig({ globalSetup: require.resolve("./global-setup"), projects: [ { name: "admin", use: { storageState: ".auth/admin.json" }, testMatch: "**/*.admin.spec.js", }, { name: "user", use: { storageState: ".auth/user.json" }, testMatch: "**/*.user.spec.js", }, { name: "viewer", use: { storageState: ".auth/viewer.json" }, testMatch: "**/*.viewer.spec.js", }, { name: "unauthenticated", use: { storageState: { cookies: [], origins: [] } }, testMatch: "**/*.anon.spec.js", }, ], }) ``` **替代方案**:当你需要在单个 spec 文件内切换角色时,使用一个接受角色参数的 fixture。 ```typescript // fixtures/auth.ts — 基于角色的 fixture import { test as base, type Page } from "@playwright/test" import fs from "fs" type RoleFixtures = { loginAs: (role: "admin" | "user" | "viewer") => Promise } export const test = base.extend({ loginAs: async ({ browser }, use) => { const pages: Page[] = [] await use(async (role) => { const statePath = `.auth/${role}.json` if (!fs.existsSync(statePath)) { throw new Error( `Auth state for role "${role}" not found at ${statePath}. Run global setup first.` ) } const context = await browser.newContext({ storageState: statePath }) const page = await context.newPage() pages.push(page) return page }) for (const page of pages) { await page.context().close() } }, }) export { expect } from "@playwright/test" ``` ```typescript // tests/role-comparison.spec.ts import { test, expect } from "../fixtures/auth" test("admin sees delete button, viewer does not", async ({ loginAs }) => { const adminPage = await loginAs("admin") await adminPage.goto("/admin/users") await expect(adminPage.getByRole("button", { name: "Delete user" })).toBeVisible() const viewerPage = await loginAs("viewer") await viewerPage.goto("/admin/users") await expect(viewerPage.getByText("Access denied")).toBeVisible() }) ``` ### OAuth/SSO 模拟 **适用场景**:你的应用通过第三方 OAuth 提供方(Google、GitHub、Okta、Auth0)进行身份验证,并且你不能或不应该在测试中访问真实的提供方。 **避免场景**:你在 OAuth 提供方上有专用的测试租户,并且需要 OAuth 流程的真实端到端覆盖。 策略:拦截 OAuth 回调路由,直接注入一个有效的会话,完全绕过提供方。 **TypeScript** ```typescript // fixtures/oauth-mock.ts import { test as base } from "@playwright/test" export const test = base.extend({ // 通过拦截回调并注入会话 cookie 来模拟 OAuth page: async ({ page }, use) => { // 拦截指向提供方的 OAuth 重定向 await page.route("**/auth/callback**", async (route) => { // 不访问 Google/GitHub,而是调用你自己的后端 // 使用你的后端在测试模式下识别的测试令牌 const url = new URL(route.request().url()) url.searchParams.set("code", "test-oauth-code") url.searchParams.set("state", url.searchParams.get("state") || "") await route.continue({ url: url.toString() }) }) await use(page) }, }) export { expect } from "@playwright/test" ``` ```typescript // tests/oauth-login.spec.ts — 方法 1:模拟回调路由 import { test, expect } from "@playwright/test" test("login via mocked OAuth flow", async ({ page }) => { // 拦截到 OAuth 提供方的重定向 await page.route("https://accounts.google.com/**", async (route) => { // 重定向回你应用的 callback,并带有一个假的 auth code const callbackUrl = new URL("http://localhost:3000/auth/callback") callbackUrl.searchParams.set("code", "mock-auth-code-12345") callbackUrl.searchParams.set("state", "expected-state-value") await route.fulfill({ status: 302, headers: { location: callbackUrl.toString() }, }) }) await page.goto("/login") await page.getByRole("button", { name: "Sign in with Google" }).click() // 你的后端必须在测试模式下接受 "mock-auth-code-12345" // 并将其交换为一个真实的会话 await page.waitForURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` ```typescript // tests/oauth-login.spec.ts — 方法 2:基于 API 的会话注入 import { test, expect } from "@playwright/test" test("bypass OAuth entirely via API session injection", async ({ page, request }) => { // 调用一个仅测试环境可用的端点,创建无需 OAuth 的会话 // 你的后端仅在 NODE_ENV=test 时暴露此端点 const response = await request.post("/api/test/create-session", { data: { email: "oauth-user@test.com", provider: "google", role: "user", }, }) expect(response.ok()).toBeTruthy() // 响应设置了会话 cookie;storageState 捕获它们 await page.context().storageState({ path: ".auth/oauth-user.json" }) // 现在导航——已经认证 await page.goto("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("login via mocked OAuth flow", async ({ page }) => { await page.route("https://accounts.google.com/**", async (route) => { const callbackUrl = new URL("http://localhost:3000/auth/callback") callbackUrl.searchParams.set("code", "mock-auth-code-12345") callbackUrl.searchParams.set("state", "expected-state-value") await route.fulfill({ status: 302, headers: { location: callbackUrl.toString() }, }) }) await page.goto("/login") await page.getByRole("button", { name: "Sign in with Google" }).click() await page.waitForURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` ```javascript test("bypass OAuth entirely via API session injection", async ({ page, request }) => { const response = await request.post("/api/test/create-session", { data: { email: "oauth-user@test.com", provider: "google", role: "user", }, }) expect(response.ok()).toBeTruthy() await page.goto("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` **后端要求**:你的后端必须暴露一个仅测试环境可用的会话创建端点(通过 `NODE_ENV=test` 保护),或者接受一个已知的测试 OAuth code。切勿将测试身份验证端点发布到生产环境。 ### MFA 处理 **适用场景**:你的应用需要双因素认证(TOTP、短信、邮件验证码),并且你需要在测试中处理它。 **避免场景**:MFA 是可选的,并且你可以为测试账号禁用它。 **策略 1**:从共享密钥生成真实的 TOTP 码。这是最可靠的方法。 **TypeScript** ```typescript // helpers/totp.ts import * as OTPAuth from "otpauth" export function generateTOTP(secret: string): string { const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30, algorithm: "SHA1", }) return totp.generate() } ``` ```typescript // tests/mfa-login.spec.ts import { test, expect } from "@playwright/test" import { generateTOTP } from "../helpers/totp" test("login with TOTP two-factor auth", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("mfa-user@test.com") await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() // MFA 挑战页面 await expect(page.getByText("Enter your authentication code")).toBeVisible() // 从测试账号的密钥生成一个有效的 TOTP const code = generateTOTP(process.env.MFA_TOTP_SECRET!) await page.getByLabel("Authentication code").fill(code) await page.getByRole("button", { name: "Verify" }).click() await page.waitForURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` **JavaScript** ```javascript // helpers/totp.js const OTPAuth = require("otpauth") function generateTOTP(secret) { const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30, algorithm: "SHA1", }) return totp.generate() } module.exports = { generateTOTP } ``` ```javascript // tests/mfa-login.spec.js const { test, expect } = require("@playwright/test") const { generateTOTP } = require("../helpers/totp") test("login with TOTP two-factor auth", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("mfa-user@test.com") await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() await expect(page.getByText("Enter your authentication code")).toBeVisible() const code = generateTOTP(process.env.MFA_TOTP_SECRET) await page.getByLabel("Authentication code").fill(code) await page.getByRole("button", { name: "Verify" }).click() await page.waitForURL("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) ``` **策略 2**:在后端层面模拟 MFA。让后端在 `NODE_ENV=test` 时接受一个已知的绕过码(例如 `000000`)。 ```typescript // 更简单但不太真实——后端在测试模式下接受绕过码 test("login with MFA bypass code", async ({ page }) => { await page.goto("/login") await page.getByLabel("Email").fill("mfa-user@test.com") await page.getByLabel("Password").fill("password") await page.getByRole("button", { name: "Sign in" }).click() // 后端在测试环境中接受 "000000" 作为有效的 MFA 码 await page.getByLabel("Authentication code").fill("000000") await page.getByRole("button", { name: "Verify" }).click() await page.waitForURL("/dashboard") }) ``` **策略 3**:在基础设施层面为测试账号禁用 MFA。设置专用的测试用户,关闭 MFA。这是可行时最简单的方法。 ### 会话刷新 **适用场景**:你的令牌在长时间测试运行期间过期,或者你的应用使用需要刷新的短寿命 JWT。 **避免场景**:你的测试套件在几分钟内完成运行,且令牌比整个运行时长更长。 **TypeScript** ```typescript // fixtures/auth-with-refresh.ts import { test as base, type BrowserContext } from "@playwright/test" import fs from "fs" type AuthFixtures = { authenticatedPage: import("@playwright/test").Page } export const test = base.extend({ authenticatedPage: async ({ browser }, use) => { const statePath = ".auth/user.json" // 检查存储的会话是否仍然有效 let context: BrowserContext if (fs.existsSync(statePath)) { context = await browser.newContext({ storageState: statePath }) const page = await context.newPage() // 快速健康检查——会话是否仍然有效? const response = await page.request.get("/api/auth/me") if (response.ok()) { await use(page) await context.close() return } // 会话已过期——关闭并重新认证 await context.close() } // 重新认证 context = await browser.newContext() const page = await context.newPage() await page.goto("/login") await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!) await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") // 保存刷新后的状态供后续测试使用 await context.storageState({ path: statePath }) await use(page) await context.close() }, }) export { expect } from "@playwright/test" ``` ```typescript // 替代方案:拦截令牌刷新请求以确保它们正常工作 import { test, expect } from "@playwright/test" test("app refreshes expired token automatically", async ({ page }) => { await page.goto("/dashboard") // 通过清除 access token cookie 来模拟令牌过期 await page.context().clearCookies({ name: "access_token" }) // 下次 API 调用应通过 refresh_token cookie 触发令牌刷新 const refreshPromise = page.waitForResponse("**/api/auth/refresh") await page.getByRole("button", { name: "Load data" }).click() const refreshResponse = await refreshPromise expect(refreshResponse.status()).toBe(200) await expect(page.getByTestId("data-table")).toBeVisible() }) ``` **JavaScript** ```javascript // fixtures/auth-with-refresh.js const { test: base } = require("@playwright/test") const fs = require("fs") const test = base.extend({ authenticatedPage: async ({ browser }, use) => { const statePath = ".auth/user.json" if (fs.existsSync(statePath)) { const context = await browser.newContext({ storageState: statePath }) const page = await context.newPage() const response = await page.request.get("/api/auth/me") if (response.ok()) { await use(page) await context.close() return } await context.close() } const context = await browser.newContext() const page = await context.newPage() await page.goto("/login") await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL) await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD) await page.getByRole("button", { name: "Sign in" }).click() await page.waitForURL("/dashboard") await context.storageState({ path: statePath }) await use(page) await context.close() }, }) module.exports = { test, expect: require("@playwright/test").expect } ``` ### 登录页面对象 **适用场景**:多个测试文件需要执行登录,并且你想要一致、可维护的登录逻辑以及适当的错误处理。 **避免场景**:你在所有地方都使用 `storageState`,并且从未在测试中通过 UI 登录(不过对于测试登录页面本身仍然有用)。 **TypeScript** ```typescript // page-objects/LoginPage.ts import { type Page, type Locator, expect } from "@playwright/test" export class LoginPage { readonly page: Page readonly emailInput: Locator readonly passwordInput: Locator readonly signInButton: Locator readonly errorMessage: Locator readonly forgotPasswordLink: Locator constructor(page: Page) { this.page = page this.emailInput = page.getByLabel("Email") this.passwordInput = page.getByLabel("Password") this.signInButton = page.getByRole("button", { name: "Sign in" }) this.errorMessage = page.getByRole("alert") this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" }) } async goto() { await this.page.goto("/login") await expect(this.signInButton).toBeVisible() } async login(email: string, password: string) { await this.emailInput.fill(email) await this.passwordInput.fill(password) await this.signInButton.click() } async loginAndWaitForDashboard(email: string, password: string) { await this.login(email, password) await this.page.waitForURL("/dashboard") } async expectError(message: string | RegExp) { await expect(this.errorMessage).toContainText(message) } async expectFieldError(field: "email" | "password", message: string) { const input = field === "email" ? this.emailInput : this.passwordInput await expect(input).toHaveAttribute("aria-invalid", "true") // 与该字段关联的错误消息 const errorId = await input.getAttribute("aria-describedby") if (errorId) { await expect(this.page.locator(`#${errorId}`)).toContainText(message) } } } ``` ```typescript // tests/login.spec.ts import { test, expect } from "@playwright/test" import { LoginPage } from "../page-objects/LoginPage" // 这些测试运行时没有 storageState(未认证) test.use({ storageState: { cookies: [], origins: [] } }) test.describe("login page", () => { let loginPage: LoginPage test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page) await loginPage.goto() }) test("successful login redirects to dashboard", async ({ page }) => { await loginPage.loginAndWaitForDashboard("user@test.com", "password") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) test("wrong password shows error", async () => { await loginPage.login("user@test.com", "wrong-password") await loginPage.expectError("Invalid email or password") }) test("empty fields show validation errors", async () => { await loginPage.signInButton.click() await loginPage.expectFieldError("email", "Email is required") }) test("forgot password link navigates correctly", async ({ page }) => { await loginPage.forgotPasswordLink.click() await page.waitForURL("/forgot-password") await expect(page.getByRole("heading", { name: "Reset password" })).toBeVisible() }) }) ``` **JavaScript** ```javascript // page-objects/LoginPage.js const { expect } = require("@playwright/test") class LoginPage { constructor(page) { this.page = page this.emailInput = page.getByLabel("Email") this.passwordInput = page.getByLabel("Password") this.signInButton = page.getByRole("button", { name: "Sign in" }) this.errorMessage = page.getByRole("alert") this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" }) } async goto() { await this.page.goto("/login") await expect(this.signInButton).toBeVisible() } async login(email, password) { await this.emailInput.fill(email) await this.passwordInput.fill(password) await this.signInButton.click() } async loginAndWaitForDashboard(email, password) { await this.login(email, password) await this.page.waitForURL("/dashboard") } async expectError(message) { await expect(this.errorMessage).toContainText(message) } async expectFieldError(field, message) { const input = field === "email" ? this.emailInput : this.passwordInput await expect(input).toHaveAttribute("aria-invalid", "true") const errorId = await input.getAttribute("aria-describedby") if (errorId) { await expect(this.page.locator(`#${errorId}`)).toContainText(message) } } } module.exports = { LoginPage } ``` ```javascript // tests/login.spec.js const { test, expect } = require("@playwright/test") const { LoginPage } = require("../page-objects/LoginPage") test.use({ storageState: { cookies: [], origins: [] } }) test.describe("login page", () => { let loginPage test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page) await loginPage.goto() }) test("successful login redirects to dashboard", async ({ page }) => { await loginPage.loginAndWaitForDashboard("user@test.com", "password") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() }) test("wrong password shows error", async () => { await loginPage.login("user@test.com", "wrong-password") await loginPage.expectError("Invalid email or password") }) test("empty fields show validation errors", async () => { await loginPage.signInButton.click() await loginPage.expectFieldError("email", "Email is required") }) }) ``` ### 基于 API 的登录 **适用场景**:你想要最快的身份验证方式,完全无需浏览器交互。非常适合在全局设置或 fixture 中生成 `storageState` 文件。 **避免场景**:你专门在测试登录 UI。此时应改用登录页面对象模式。 API 登录通常比 UI 登录快 5-10 倍。直接使用 `request.post()` 访问你的身份验证端点,然后捕获产生的 cookie。 **TypeScript** ```typescript // global-setup.ts — 基于 API 的登录(最快) import { request, type FullConfig } from "@playwright/test" async function globalSetup(config: FullConfig) { const { baseURL } = config.projects[0].use const requestContext = await request.newContext({ baseURL }) // 通过 API 登录——无需浏览器 const response = await requestContext.post("/api/auth/login", { data: { email: process.env.TEST_USER_EMAIL!, password: process.env.TEST_USER_PASSWORD!, }, }) if (!response.ok()) { throw new Error(`API login failed: ${response.status()} ${await response.text()}`) } // 从 API 响应中保存会话 cookie await requestContext.storageState({ path: ".auth/user.json" }) await requestContext.dispose() } export default globalSetup ``` ```typescript // fixtures/api-auth.ts — 用于每个测试身份验证的 fixture 版本 import { test as base } from "@playwright/test" export const test = base.extend({ authenticatedPage: async ({ browser, playwright }, use) => { // 创建 API 上下文并登录 const apiContext = await playwright.request.newContext({ baseURL: "http://localhost:3000", }) await apiContext.post("/api/auth/login", { data: { email: "user@test.com", password: "password", }, }) // 将 API 会话传输到浏览器上下文 const state = await apiContext.storageState() const context = await browser.newContext({ storageState: state }) const page = await context.newPage() await use(page) await context.close() await apiContext.dispose() }, }) export { expect } from "@playwright/test" ``` **JavaScript** ```javascript // global-setup.js — 基于 API 的登录 const { request } = require("@playwright/test") async function globalSetup(config) { const { baseURL } = config.projects[0].use const requestContext = await request.newContext({ baseURL }) const response = await requestContext.post("/api/auth/login", { data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD, }, }) if (!response.ok()) { throw new Error(`API login failed: ${response.status()} ${await response.text()}`) } await requestContext.storageState({ path: ".auth/user.json" }) await requestContext.dispose() } module.exports = globalSetup ``` ```javascript // fixtures/api-auth.js const { test: base } = require("@playwright/test") const test = base.extend({ authenticatedPage: async ({ browser, playwright }, use) => { const apiContext = await playwright.request.newContext({ baseURL: "http://localhost:3000", }) await apiContext.post("/api/auth/login", { data: { email: "user@test.com", password: "password" }, }) const state = await apiContext.storageState() const context = await browser.newContext({ storageState: state }) const page = await context.newPage() await use(page) await context.close() await apiContext.dispose() }, }) module.exports = { test, expect: require("@playwright/test").expect } ``` ### 未认证测试 **适用场景**:测试登录页面、注册流程、密码重置、公共页面、身份验证错误处理,或未认证用户的重定向行为。 **避免场景**:测试需要已登录用户。 当你的配置设置了默认的 `storageState` 时,你必须为未认证测试显式清除它。 **TypeScript** ```typescript // tests/public-pages.spec.ts import { test, expect } from "@playwright/test" // 覆盖 storageState 为空——无 cookie,无会话 test.use({ storageState: { cookies: [], origins: [] } }) test.describe("unauthenticated access", () => { test("homepage is accessible without login", async ({ page }) => { await page.goto("/") await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible() await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible() }) test("protected route redirects to login", async ({ page }) => { await page.goto("/dashboard") await page.waitForURL("**/login**") // 验证重定向保留了目标地址 expect(page.url()).toContain("redirect=%2Fdashboard") }) test("expired session shows re-login prompt", async ({ page, context }) => { // 从有效会话开始,然后使其失效 await page.goto("/dashboard") // 清除所有 cookie 以模拟会话过期 await context.clearCookies() // 下次导航应检测到无效会话 await page.goto("/settings") await page.waitForURL("**/login**") await expect(page.getByText("Your session has expired")).toBeVisible() }) test("login page shows social auth options", async ({ page }) => { await page.goto("/login") await expect(page.getByRole("button", { name: "Sign in with Google" })).toBeVisible() await expect(page.getByRole("button", { name: "Sign in with GitHub" })).toBeVisible() }) test("signup flow creates account", async ({ page }) => { await page.goto("/signup") await page.getByLabel("Name").fill("New User") await page.getByLabel("Email").fill(`test-${Date.now()}@test.com`) await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass") await page.getByLabel("Confirm password").fill("s3cure!Pass") await page.getByRole("button", { name: "Create account" }).click() await page.waitForURL("/onboarding") await expect(page.getByText("Welcome, New User")).toBeVisible() }) }) ``` **JavaScript** ```javascript // tests/public-pages.spec.js const { test, expect } = require("@playwright/test") test.use({ storageState: { cookies: [], origins: [] } }) test.describe("unauthenticated access", () => { test("homepage is accessible without login", async ({ page }) => { await page.goto("/") await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible() await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible() }) test("protected route redirects to login", async ({ page }) => { await page.goto("/dashboard") await page.waitForURL("**/login**") expect(page.url()).toContain("redirect=%2Fdashboard") }) test("signup flow creates account", async ({ page }) => { await page.goto("/signup") await page.getByLabel("Name").fill("New User") await page.getByLabel("Email").fill(`test-${Date.now()}@test.com`) await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass") await page.getByLabel("Confirm password").fill("s3cure!Pass") await page.getByRole("button", { name: "Create account" }).click() await page.waitForURL("/onboarding") await expect(page.getByText("Welcome, New User")).toBeVisible() }) }) ``` ## 决策指南 | 场景 | 方法 | 速度 | 隔离性 | 何时选择 | | -------------------------------- | ------------------------------ | -------- | -------------- | ----------------------------------------------------------------------------------------------------------- | | 大多数测试需要身份验证 | 全局设置 + `storageState` | 最快 | 共享会话 | 几乎每个项目的默认方案。登录只发生一次,所有测试复用该会话文件。 | | 测试修改用户状态 | 每 worker fixture | 快 | 每 worker | 测试更新个人资料、更改设置或修改可能在并行 worker 间冲突的数据。 | | 多个用户角色 | 每项目 `storageState` | 最快 | 每角色 | 应用有 admin/user/viewer 角色。每个项目从全局设置获取自己的状态文件。 | | 测试登录页面 | 不使用 `storageState` | N/A | 完整 | 使用 `test.use({ storageState: { cookies: [], origins: [] } })` 覆盖默认值。 | | OAuth/SSO 提供方 | 模拟回调 | 快 | 每测试 | 绝不要在 CI 中访问真实的 OAuth 提供方。模拟重定向或使用 API 会话注入。 | | MFA 是必需的 | TOTP 生成或绕过 | 中等 | 每测试 | 从共享密钥生成真实的 TOTP 码,或使用测试模式的绕过码。 | | 令牌在套件运行期间过期 | 会话刷新 fixture | 快 | 每次检查 | Fixture 在使用前验证会话有效性,并在过期时重新认证。 | | 单个测试需要不同用户 | `loginAs(role)` fixture | 中等 | 每次调用 | 较少使用:优先选择每项目角色。当单个测试需要并排比较两个角色时使用。 | | API 优先应用(无登录 UI) | 通过 `request.post()` 的 API 登录 | 最快 | 每测试 | 身份验证无需浏览器。直接调用 API 并捕获 cookie。 | | CI 中的长时间运行套件 | API 登录 + 会话检查 | 最快 | 每 worker | 将 API 登录的速度与会话刷新的可靠性结合,适合长时间运行。 | ### UI 登录 vs API 登录 vs Storage State ``` 需要测试登录页面本身吗? ├── 是 → 使用 LoginPage POM 的 UI 登录,不使用 storageState └── 否 → 你有登录 API 端点吗? ├── 是 → 在全局设置中使用 API 登录,保存 storageState(最快) └── 否 → 在全局设置中使用 UI 登录,保存 storageState └── 令牌过期很快吗? ├── 是 → 添加会话刷新 fixture └── 否 → 标准 storageState 复用即可 ``` ## 反模式 | 不要这样做 | 问题 | 改为这样做 | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | 在每个测试前通过 UI 登录 | 每个测试增加 2-5 秒。200 个测试的套件在登录上浪费 7-17 分钟。 | 使用 `storageState` 完全跳过登录。在全局设置中只登录一次。 | | 在修改状态的并行 worker 之间共享单个 auth state 文件 | 竞态条件:worker A 更改了密码,而 worker B 正在测试过程中。 | 使用 `{ scope: 'worker' }` 的每 worker fixture 或每 worker 测试账号。 | | 在测试文件中硬编码凭据 | 安全风险。凭据会泄露到版本控制和 CI 日志中。 | 使用环境变量(`process.env.TEST_USER_PASSWORD`)和 `.env` 文件。 | | 忽略令牌过期 | 测试运行一段时间后间歇性出现 401 错误。 | 在 auth fixture 中添加会话有效性检查,过期时重新认证。 | | 在 CI 中访问真实的 OAuth 提供方 | 不稳定:提供方速率限制、CAPTCHA、网络问题。速度慢。可能违反服务条款。 | 模拟 OAuth 回调或使用仅测试端点的 API 会话注入。 | | 在登录后使用 `page.waitForTimeout(2000)` | 任意延迟。在快速环境中太慢,在慢速环境中太短。 | 使用 `await page.waitForURL('/dashboard')` 或 `await expect(heading).toBeVisible()`。 | | 将 `.auth/*.json` 文件存储到 git 中 | 令牌和 cookie 进入版本控制。安全隐患。 | 将 `.auth/` 添加到 `.gitignore`。在 CI 中作为测试运行的一部分生成 auth 状态。 | | 创建一个拥有所有权限的"神级"测试账号 | 无法测试基于角色的访问控制。权限检查的 bug 被遗漏。 | 为每个角色(admin、user、viewer)创建独立的账号,并赋予相应的权限。 | | 在已认证测试中使用 `browser.newContext()` 而不带 `storageState` | 每个上下文都以未认证状态启动。最终你不得不不断重新登录。 | 在创建上下文时传递 `storageState`:`browser.newContext({ storageState: '.auth/user.json' })`。 | | 通过在所有地方禁用 MFA 来测试 MFA | 你从未测试过 MFA 流程。真实用户遇到你从未捕获的 MFA bug。 | 至少在一个测试中使用共享密钥生成 TOTP。其余测试绕过 MFA。 | ## 故障排除 ### 全局设置失败,提示"Target page, context or browser has been closed" **原因**:登录页面意外重定向,或者在调用 `storageState()` 之前浏览器已关闭。常见于登录 URL 要求 HTTPS 但你的测试服务器使用 HTTP。 **解决方法**: - 在登录操作后添加 `await page.waitForURL()` 以确保导航完成。 - 检查配置中的 `baseURL` 是否与实际服务器 URL 和协议匹配。 - 在全局设置中添加错误处理,并附带有意义的错误消息: ```typescript const response = await page.waitForResponse("**/api/auth/**") if (!response.ok()) { throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`) } ``` ### 测试运行一段时间后失败,提示 401 Unauthorized **原因**:保存在 `storageState` 中的会话令牌已过期。短寿命 JWT(15 分钟过期)无法在长测试套件中存活。 **解决方法**: - 使用会话刷新 fixture 模式(参见会话刷新部分)。 - 在测试环境配置中增加令牌过期时间。 - 切换到基于 API 的登录,使用 worker 作用域的 fixture,每个 worker 重新认证。 ### `storageState` 文件为空或不包含 cookie **原因**:在登录响应设置 cookie 之前调用了 `storageState()`。登录可能是异步的(POST 返回后,重定向才设置 cookie)。 **解决方法**: - 等待登录后页面加载:`await page.waitForURL('/dashboard')`。 - 等待特定的 cookie:在轮询检查中使用 `await context.cookies()`。 - 在保存前验证 cookie 是否存在: ```typescript const cookies = await context.cookies() if (cookies.length === 0) { throw new Error("No cookies found after login. Check that the login flow sets cookies.") } await context.storageState({ path: ".auth/user.json" }) ``` ### 不同浏览器获取不同的 cookie(跨浏览器身份验证问题) **原因**:某些身份验证流程设置了 `SameSite=Strict` 的 cookie,或使用了浏览器特定的 cookie 行为。Chromium 和 Firefox 对第三方 cookie 的处理不同。 **解决方法**: - 为每个浏览器项目生成独立的 auth state 文件。 - 检查你的身份验证是否使用了需要 HTTPS 的 `SameSite=None; Secure` cookie。 - 在 `playwright.config` 中,为每个项目设置不同的 `storageState` 路径: ```typescript projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' }, }, ], ``` ### 并行测试互相干扰会话 **原因**:多个 worker 共享同一个测试账号,一个 worker 的操作(注销、密码更改、会话失效)会影响其他 worker。 **解决方法**: - 使用每 worker 测试账号:`worker-${test.info().parallelIndex}@test.com`。 - 使用每 worker 身份验证 fixture 模式(参见每 Worker 身份验证部分)。 - 使测试幂等:每个测试应独立工作,不受其他测试操作的影响。 ### OAuth 模拟不起作用——仍然重定向到真实提供方 **原因**:`page.route()` 在触发 OAuth 重定向的导航之后注册,或者路由模式与实际的重定向 URL 不匹配。 **解决方法**: - 在任何导航之前注册路由处理器:在 `page.goto()` 之前调用 `page.route()`。 - 记录实际的重定向 URL 以验证模式: ```typescript page.on("request", (req) => { if (req.url().includes("oauth") || req.url().includes("accounts.google")) { console.log("OAuth request:", req.url()) } }) ``` - 先使用宽泛模式(`**accounts.google.com**`),然后逐步缩小。 ## 相关文档 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- 用于 auth 设置和清理的自定义 fixture - [core/configuration.md](configuration.md) -- `storageState`、项目和全局设置配置 - [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) -- 全局设置模式和项目依赖 - [core/network-mocking.md](network-mocking.md) -- OAuth 模拟中使用的路由拦截模式 - [core/api-testing.md](api-testing.md) -- 基于 API 的登录中使用的 API 请求上下文 - [core/flaky-tests.md](flaky-tests.md) -- 诊断与身份验证相关的不稳定性 - [core/auth-flows.md](auth-flows.md) -- 特定身份验证提供方(Auth0、Okta、Firebase)的完整方案