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