32 KiB
32 KiB
身份验证流程配方
适用场景:你需要测试登录、注册、退出、会话管理或任何与身份验证相关的用户流程。
配方 1:基本登录
完整示例
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
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
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
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
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
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
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
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
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
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
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<Role, { email: string; password: string }> = {
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
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
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
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
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
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
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" })
})
多因素认证
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")
})
使用不同浏览器上下文测试认证
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()
})
提示
-
使用
storageState实现可复用的认证。在设置项目中运行一次登录,将状态保存到 JSON 文件,然后在所有测试中复用。这样只需为每个角色登录一次,大幅提升测试套件的运行速度。 -
切勿在测试文件中硬编码凭据。使用环境变量或通过
dotenv加载的.env文件。在playwright.config.ts中设置process.env值,或使用use中的env选项。 -
非认证测试优先使用基于 API 的登录。仅在认证相关测试中测试登录 UI。其他所有情况,通过 API 或恢复
storageState来完成认证。这样测试更快、更稳定。 -
同时测试负面路径。无效凭据、过期 token、账户被锁定、速率限制——这些是用户在生产环境中实际遇到的场景,却常常测试不足。
-
在测试环境中设置更短的会话超时时间。如果你的应用有 30 分钟的会话超时,在测试环境中将其配置为 30 秒,这样无需让测试变慢就能测试超时行为。
相关
- Playwright 认证文档
patterns/page-objects.md——将登录流程封装到页面对象中foundations/assertions.md——认证状态的断言模式recipes/crud-testing.md——认证后测试 CRUD 操作