50 KiB
身份验证测试
使用场景:任何包含登录、会话管理或受保护路由的应用。身份验证是测试套件最常见的速度瓶颈——处理好这部分,整个测试套件都会提速。 前置条件:core/configuration.md、core/fixtures-and-hooks.md
快速参考
// 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
// 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()
// playwright.config.ts — 为所有测试加载已保存的状态
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
baseURL: "http://localhost:3000",
storageState: ".auth/user.json",
},
})
// 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
// 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()
// 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
// 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
// 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
// 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
// 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
// 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"
// 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
// 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
// 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
// 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",
},
],
})
// 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()
})
// 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
// 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
// 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。
// 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<Page>
}
export const test = base.extend<RoleFixtures>({
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"
// 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
// 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"
// 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()
})
// 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
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()
})
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
// 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()
}
// 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
// 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 }
// 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)。
// 更简单但不太真实——后端在测试模式下接受绕过码
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
// 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<AuthFixtures>({
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"
// 替代方案:拦截令牌刷新请求以确保它们正常工作
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
// 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
// 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)
}
}
}
// 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
// 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 }
// 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
// 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
// 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
// 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
// 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
// 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
// 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 和协议匹配。 - 在全局设置中添加错误处理,并附带有意义的错误消息:
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 是否存在:
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; Securecookie。 - 在
playwright.config中,为每个项目设置不同的storageState路径:
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 以验证模式:
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 -- 用于 auth 设置和清理的自定义 fixture
- core/configuration.md --
storageState、项目和全局设置配置 - ci/global-setup-teardown.md -- 全局设置模式和项目依赖
- core/network-mocking.md -- OAuth 模拟中使用的路由拦截模式
- core/api-testing.md -- 基于 API 的登录中使用的 API 请求上下文
- core/flaky-tests.md -- 诊断与身份验证相关的不稳定性
- core/auth-flows.md -- 特定身份验证提供方(Auth0、Okta、Firebase)的完整方案