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