1234 lines
36 KiB
Markdown
1234 lines
36 KiB
Markdown
# 测试数据管理
|
||
|
||
> **使用时机**:每个需要与数据交互的测试——用户账户、表单输入、实体,或任何在断言运行前必须存在的状态。
|
||
|
||
## 快速参考
|
||
|
||
| 策略 | 速度 | 隔离性 | 复杂度 | 最佳适用场景 |
|
||
| ----------------- | -------- | -------- | ------ | --------------------------------------- |
|
||
| 内联数据 | 即时 | 完美 | 无 | 简单的值检查、表单输入 |
|
||
| 工厂函数 | 即时 | 完美 | 低 | 唯一标识符、一致的形状 |
|
||
| Faker/随机数据 | 即时 | 完美 | 低 | 逼真的字段、边界情况发现 |
|
||
| Builder 模式 | 即时 | 完美 | 中 | 含多个可选字段的复杂对象 |
|
||
| API 种子数据 | 快 | 完美 | 中 | 创建应用依赖的实体 |
|
||
| 数据库种子数据 | 快 | 良好 | 高 | 复杂关系型数据、批量设置 |
|
||
| 存储状态 | 快 | 良好 | 低 | 复用已认证的会话 |
|
||
| 基于 Fixture 的设置 | 快 | 完美 | 中 | 封装设置 + 保证清理 |
|
||
|
||
**核心原则**:每个测试创建自己的数据,并在完成后自行清理。没有测试应依赖于另一个测试留下的数据或环境中预先存在的数据。
|
||
|
||
## 模式
|
||
|
||
### 内联测试数据
|
||
|
||
**使用时机**:数据简单、仅用于一个测试,并且对理解断言至关重要时。
|
||
|
||
**避免时机**:相同的数据形状在多个测试中重复出现时——此时应提取为工厂函数。
|
||
|
||
内联数据让测试自文档化。读者一眼就能看到什么重要,无需去追踪 import。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/contact-form.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("使用有效数据提交联系表单", async ({ page }) => {
|
||
const name = "Ada Lovelace"
|
||
const email = `ada-${Date.now()}@example.com`
|
||
const message = "关于分析引擎的咨询"
|
||
|
||
await page.goto("/contact")
|
||
await page.getByLabel("姓名").fill(name)
|
||
await page.getByLabel("邮箱").fill(email)
|
||
await page.getByLabel("留言").fill(message)
|
||
await page.getByRole("button", { name: "发送" }).click()
|
||
|
||
await expect(page.getByText("谢谢,Ada Lovelace")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/contact-form.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("使用有效数据提交联系表单", async ({ page }) => {
|
||
const name = "Ada Lovelace"
|
||
const email = `ada-${Date.now()}@example.com`
|
||
const message = "关于分析引擎的咨询"
|
||
|
||
await page.goto("/contact")
|
||
await page.getByLabel("姓名").fill(name)
|
||
await page.getByLabel("邮箱").fill(email)
|
||
await page.getByLabel("留言").fill(message)
|
||
await page.getByRole("button", { name: "发送" }).click()
|
||
|
||
await expect(page.getByText("谢谢,Ada Lovelace")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
对必须唯一的字段(邮箱、用户名),使用 `Date.now()` 或 `crypto.randomUUID()`。这可以防止并行执行时的冲突。
|
||
|
||
---
|
||
|
||
### 工厂函数
|
||
|
||
**使用时机**:多个测试需要相同的数据形状但不同的值,或者需要保证唯一性时。
|
||
|
||
**避免时机**:数据很简单且仅使用一次时——直接内联即可。
|
||
|
||
工厂函数集中管理数据创建逻辑。当数据形状变化时,只需更新一个函数,而不是几十个测试。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/factories/user.factory.ts
|
||
export interface UserData {
|
||
firstName: string
|
||
lastName: string
|
||
email: string
|
||
password: string
|
||
}
|
||
|
||
let counter = 0
|
||
|
||
export function createUserData(overrides: Partial<UserData> = {}): UserData {
|
||
counter++
|
||
const id = `${Date.now()}-${counter}`
|
||
return {
|
||
firstName: `Test`,
|
||
lastName: `User${id}`,
|
||
email: `testuser-${id}@example.com`,
|
||
password: "SecureP@ss123!",
|
||
...overrides,
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// tests/registration.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
import { createUserData } from "./factories/user.factory"
|
||
|
||
test("注册新用户", async ({ page }) => {
|
||
const user = createUserData()
|
||
|
||
await page.goto("/register")
|
||
await page.getByLabel("名").fill(user.firstName)
|
||
await page.getByLabel("姓").fill(user.lastName)
|
||
await page.getByLabel("邮箱").fill(user.email)
|
||
await page.getByLabel("密码").fill(user.password)
|
||
await page.getByRole("button", { name: "创建账户" }).click()
|
||
|
||
await expect(page.getByText(`欢迎,${user.firstName}`)).toBeVisible()
|
||
})
|
||
|
||
test("拒绝重复邮箱", async ({ page }) => {
|
||
const user = createUserData({ email: "duplicate@example.com" })
|
||
|
||
await page.goto("/register")
|
||
await page.getByLabel("邮箱").fill(user.email)
|
||
await page.getByLabel("密码").fill(user.password)
|
||
await page.getByRole("button", { name: "创建账户" }).click()
|
||
|
||
await expect(page.getByText("邮箱已被注册")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/factories/user.factory.js
|
||
let counter = 0
|
||
|
||
function createUserData(overrides = {}) {
|
||
counter++
|
||
const id = `${Date.now()}-${counter}`
|
||
return {
|
||
firstName: "Test",
|
||
lastName: `User${id}`,
|
||
email: `testuser-${id}@example.com`,
|
||
password: "SecureP@ss123!",
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
module.exports = { createUserData }
|
||
```
|
||
|
||
```javascript
|
||
// tests/registration.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
const { createUserData } = require("./factories/user.factory")
|
||
|
||
test("注册新用户", async ({ page }) => {
|
||
const user = createUserData()
|
||
|
||
await page.goto("/register")
|
||
await page.getByLabel("名").fill(user.firstName)
|
||
await page.getByLabel("姓").fill(user.lastName)
|
||
await page.getByLabel("邮箱").fill(user.email)
|
||
await page.getByLabel("密码").fill(user.password)
|
||
await page.getByRole("button", { name: "创建账户" }).click()
|
||
|
||
await expect(page.getByText(`欢迎,${user.firstName}`)).toBeVisible()
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### Faker / 随机数据
|
||
|
||
**使用时机**:需要看起来逼真的数据(姓名、地址、电话号码),或希望通过随机性发现边界情况时。
|
||
|
||
**避免时机**:调试失败时——应使用固定种子以确保可复现。
|
||
|
||
始终为 faker 设置种子,以便失败时可复现。使用 `testInfo.testId` 或每个测试文件的固定种子。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/factories/faker-user.factory.ts
|
||
import { faker } from "@faker-js/faker"
|
||
|
||
export function createFakerUser(seed?: number) {
|
||
if (seed !== undefined) {
|
||
faker.seed(seed)
|
||
}
|
||
return {
|
||
firstName: faker.person.firstName(),
|
||
lastName: faker.person.lastName(),
|
||
email: faker.internet.email({ provider: "testmail.example.com" }),
|
||
phone: faker.phone.number(),
|
||
address: {
|
||
street: faker.location.streetAddress(),
|
||
city: faker.location.city(),
|
||
state: faker.location.state({ abbreviated: true }),
|
||
zip: faker.location.zipCode(),
|
||
},
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// tests/checkout.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
import { createFakerUser } from "./factories/faker-user.factory"
|
||
|
||
test("使用收货地址完成结账", async ({ page }, testInfo) => {
|
||
// 使用稳定值作为种子,以便重新运行时产生相同的数据
|
||
const user = createFakerUser(testInfo.workerIndex)
|
||
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("街道地址").fill(user.address.street)
|
||
await page.getByLabel("城市").fill(user.address.city)
|
||
await page.getByLabel("州/省").fill(user.address.state)
|
||
await page.getByLabel("邮政编码").fill(user.address.zip)
|
||
await page.getByRole("button", { name: "下单" }).click()
|
||
|
||
await expect(page.getByText("订单已确认")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/factories/faker-user.factory.js
|
||
const { faker } = require("@faker-js/faker")
|
||
|
||
function createFakerUser(seed) {
|
||
if (seed !== undefined) {
|
||
faker.seed(seed)
|
||
}
|
||
return {
|
||
firstName: faker.person.firstName(),
|
||
lastName: faker.person.lastName(),
|
||
email: faker.internet.email({ provider: "testmail.example.com" }),
|
||
phone: faker.phone.number(),
|
||
address: {
|
||
street: faker.location.streetAddress(),
|
||
city: faker.location.city(),
|
||
state: faker.location.state({ abbreviated: true }),
|
||
zip: faker.location.zipCode(),
|
||
},
|
||
}
|
||
}
|
||
|
||
module.exports = { createFakerUser }
|
||
```
|
||
|
||
```javascript
|
||
// tests/checkout.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
const { createFakerUser } = require("./factories/faker-user.factory")
|
||
|
||
test("使用收货地址完成结账", async ({ page }, testInfo) => {
|
||
const user = createFakerUser(testInfo.workerIndex)
|
||
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("街道地址").fill(user.address.street)
|
||
await page.getByLabel("城市").fill(user.address.city)
|
||
await page.getByLabel("州/省").fill(user.address.state)
|
||
await page.getByLabel("邮政编码").fill(user.address.zip)
|
||
await page.getByRole("button", { name: "下单" }).click()
|
||
|
||
await expect(page.getByText("订单已确认")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
始终使用测试专用的邮箱域名(例如 `testmail.example.com`),这样 faker 生成的邮箱永远不会到达真实的收件箱。`example.com` 域名由 RFC 2606 保留,用于测试是安全的。
|
||
|
||
---
|
||
|
||
### Builder 模式
|
||
|
||
**使用时机**:对象有许多可选字段、条件逻辑,或多种有效配置时。常见于商品列表、用户资料,或带有嵌套数据的表单负载。
|
||
|
||
**避免时机**:对象字段少于 5 个时——带覆盖参数的工厂函数更简单。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/builders/product.builder.ts
|
||
export interface Product {
|
||
name: string
|
||
price: number
|
||
currency: string
|
||
category: string
|
||
description: string
|
||
inStock: boolean
|
||
tags: string[]
|
||
variants: { size: string; color: string }[]
|
||
}
|
||
|
||
export class ProductBuilder {
|
||
private product: Product = {
|
||
name: `Product-${Date.now()}`,
|
||
price: 29.99,
|
||
currency: "USD",
|
||
category: "Electronics",
|
||
description: "一个测试商品",
|
||
inStock: true,
|
||
tags: [],
|
||
variants: [],
|
||
}
|
||
|
||
withName(name: string): this {
|
||
this.product.name = name
|
||
return this
|
||
}
|
||
|
||
withPrice(price: number, currency = "USD"): this {
|
||
this.product.price = price
|
||
this.product.currency = currency
|
||
return this
|
||
}
|
||
|
||
withCategory(category: string): this {
|
||
this.product.category = category
|
||
return this
|
||
}
|
||
|
||
outOfStock(): this {
|
||
this.product.inStock = false
|
||
return this
|
||
}
|
||
|
||
withTags(...tags: string[]): this {
|
||
this.product.tags = tags
|
||
return this
|
||
}
|
||
|
||
withVariant(size: string, color: string): this {
|
||
this.product.variants.push({ size, color })
|
||
return this
|
||
}
|
||
|
||
build(): Product {
|
||
return { ...this.product }
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// tests/product-catalog.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
import { ProductBuilder } from "./builders/product.builder"
|
||
|
||
test("为缺货商品显示缺货徽章", async ({ page, request }) => {
|
||
const product = new ProductBuilder()
|
||
.withName("无线键盘")
|
||
.withCategory("配件")
|
||
.outOfStock()
|
||
.build()
|
||
|
||
// 通过 API 写入种子数据(见下方的 API 种子数据模式)
|
||
await request.post("/api/products", { data: product })
|
||
|
||
await page.goto("/products")
|
||
const card = page.getByRole("listitem").filter({ hasText: product.name })
|
||
await expect(card.getByText("缺货")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/builders/product.builder.js
|
||
class ProductBuilder {
|
||
constructor() {
|
||
this.product = {
|
||
name: `Product-${Date.now()}`,
|
||
price: 29.99,
|
||
currency: "USD",
|
||
category: "Electronics",
|
||
description: "一个测试商品",
|
||
inStock: true,
|
||
tags: [],
|
||
variants: [],
|
||
}
|
||
}
|
||
|
||
withName(name) {
|
||
this.product.name = name
|
||
return this
|
||
}
|
||
|
||
withPrice(price, currency = "USD") {
|
||
this.product.price = price
|
||
this.product.currency = currency
|
||
return this
|
||
}
|
||
|
||
withCategory(category) {
|
||
this.product.category = category
|
||
return this
|
||
}
|
||
|
||
outOfStock() {
|
||
this.product.inStock = false
|
||
return this
|
||
}
|
||
|
||
withTags(...tags) {
|
||
this.product.tags = tags
|
||
return this
|
||
}
|
||
|
||
withVariant(size, color) {
|
||
this.product.variants.push({ size, color })
|
||
return this
|
||
}
|
||
|
||
build() {
|
||
return { ...this.product }
|
||
}
|
||
}
|
||
|
||
module.exports = { ProductBuilder }
|
||
```
|
||
|
||
```javascript
|
||
// tests/product-catalog.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
const { ProductBuilder } = require("./builders/product.builder")
|
||
|
||
test("为缺货商品显示缺货徽章", async ({ page, request }) => {
|
||
const product = new ProductBuilder()
|
||
.withName("无线键盘")
|
||
.withCategory("配件")
|
||
.outOfStock()
|
||
.build()
|
||
|
||
await request.post("/api/products", { data: product })
|
||
|
||
await page.goto("/products")
|
||
const card = page.getByRole("listitem").filter({ hasText: product.name })
|
||
await expect(card.getByText("缺货")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### API 种子数据
|
||
|
||
**使用时机**:测试需要实体已存在(用户、商品、订单),且应用提供了创建这些实体的 API 时。这是测试数据设置的首选策略。
|
||
|
||
**避免时机**:该实体没有对应的 API,或者测试的目标正是该 API 本身时(应改用 UI 或数据库)。
|
||
|
||
API 种子数据比基于 UI 的设置更快,比数据库种子数据更易维护,并且执行了真实的应用逻辑。当 API 可用时,优先采用此方法。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/fixtures/api-data.fixture.ts
|
||
import { test as base, APIRequestContext } from "@playwright/test"
|
||
import { createUserData, UserData } from "../factories/user.factory"
|
||
|
||
type ApiDataFixtures = {
|
||
apiUser: UserData & { id: string }
|
||
}
|
||
|
||
export const test = base.extend<ApiDataFixtures>({
|
||
apiUser: async ({ request }, use) => {
|
||
const userData = createUserData()
|
||
|
||
// 创建
|
||
const response = await request.post("/api/users", { data: userData })
|
||
const created = await response.json()
|
||
const user = { ...userData, id: created.id }
|
||
|
||
// 提供给测试使用
|
||
await use(user)
|
||
|
||
// 清理——即使测试失败也会执行
|
||
await request.delete(`/api/users/${user.id}`)
|
||
},
|
||
})
|
||
|
||
export { expect } from "@playwright/test"
|
||
```
|
||
|
||
```typescript
|
||
// tests/user-profile.spec.ts
|
||
import { test, expect } from "./fixtures/api-data.fixture"
|
||
|
||
test("编辑用户资料姓名", async ({ page, apiUser }) => {
|
||
await page.goto(`/users/${apiUser.id}/profile`)
|
||
await page.getByLabel("名").fill("Updated")
|
||
await page.getByRole("button", { name: "保存" }).click()
|
||
|
||
await expect(page.getByText("资料已更新")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/fixtures/api-data.fixture.js
|
||
const { test: base } = require("@playwright/test")
|
||
const { createUserData } = require("../factories/user.factory")
|
||
|
||
const test = base.extend({
|
||
apiUser: async ({ request }, use) => {
|
||
const userData = createUserData()
|
||
|
||
const response = await request.post("/api/users", { data: userData })
|
||
const created = await response.json()
|
||
const user = { ...userData, id: created.id }
|
||
|
||
await use(user)
|
||
|
||
await request.delete(`/api/users/${user.id}`)
|
||
},
|
||
})
|
||
|
||
module.exports = { test, expect: require("@playwright/test").expect }
|
||
```
|
||
|
||
```javascript
|
||
// tests/user-profile.spec.js
|
||
const { test, expect } = require("./fixtures/api-data.fixture")
|
||
|
||
test("编辑用户资料姓名", async ({ page, apiUser }) => {
|
||
await page.goto(`/users/${apiUser.id}/profile`)
|
||
await page.getByLabel("名").fill("Updated")
|
||
await page.getByRole("button", { name: "保存" }).click()
|
||
|
||
await expect(page.getByText("资料已更新")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
对于多实体种子数据,组合使用 fixtures:
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/fixtures/order-data.fixture.ts
|
||
import { test as base } from "./api-data.fixture"
|
||
|
||
export const test = base.extend({
|
||
apiOrder: async ({ request, apiUser }, use) => {
|
||
const orderResponse = await request.post("/api/orders", {
|
||
data: { userId: apiUser.id, items: [{ sku: "WIDGET-001", qty: 2 }] },
|
||
})
|
||
const order = await orderResponse.json()
|
||
|
||
await use(order)
|
||
|
||
await request.delete(`/api/orders/${order.id}`)
|
||
},
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 数据库种子数据
|
||
|
||
**使用时机**:没有用于所需数据的 API,需要批量数据,或者需要通过 API 调用难以设置的复杂关系状态时。
|
||
|
||
**避免时机**:存在 API 时——API 种子数据更易维护,并且执行了真实的应用逻辑。数据库种子数据会将测试与数据库模式细节耦合。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/fixtures/db.fixture.ts
|
||
import { test as base } from "@playwright/test"
|
||
import { Pool } from "pg"
|
||
|
||
type DbFixtures = {
|
||
db: Pool
|
||
seededOrg: { id: string; name: string }
|
||
}
|
||
|
||
export const test = base.extend<DbFixtures>({
|
||
db: async ({}, use) => {
|
||
const pool = new Pool({
|
||
connectionString: process.env.TEST_DATABASE_URL,
|
||
})
|
||
await use(pool)
|
||
await pool.end()
|
||
},
|
||
|
||
seededOrg: async ({ db }, use) => {
|
||
const orgName = `TestOrg-${Date.now()}`
|
||
const result = await db.query(
|
||
"INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING id",
|
||
[orgName, "enterprise"]
|
||
)
|
||
const orgId = result.rows[0].id
|
||
|
||
await use({ id: orgId, name: orgName })
|
||
|
||
// 级联删除清理相关数据
|
||
await db.query("DELETE FROM organizations WHERE id = $1", [orgId])
|
||
},
|
||
})
|
||
|
||
export { expect } from "@playwright/test"
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/fixtures/db.fixture.js
|
||
const { test: base } = require("@playwright/test")
|
||
const { Pool } = require("pg")
|
||
|
||
const test = base.extend({
|
||
db: async ({}, use) => {
|
||
const pool = new Pool({
|
||
connectionString: process.env.TEST_DATABASE_URL,
|
||
})
|
||
await use(pool)
|
||
await pool.end()
|
||
},
|
||
|
||
seededOrg: async ({ db }, use) => {
|
||
const orgName = `TestOrg-${Date.now()}`
|
||
const result = await db.query(
|
||
"INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING id",
|
||
[orgName, "enterprise"]
|
||
)
|
||
const orgId = result.rows[0].id
|
||
|
||
await use({ id: orgId, name: orgName })
|
||
|
||
await db.query("DELETE FROM organizations WHERE id = $1", [orgId])
|
||
},
|
||
})
|
||
|
||
module.exports = { test, expect: require("@playwright/test").expect }
|
||
```
|
||
|
||
始终使用参数化查询(`$1`、`$2`)以防止 SQL 注入——即使在测试中也是如此。这是良好的习惯,并能防止生成数据中的特殊字符导致问题。
|
||
|
||
---
|
||
|
||
### 存储状态
|
||
|
||
**使用时机**:多个测试需要已认证的会话,并且希望避免在每个测试中通过 UI 登录时。
|
||
|
||
**避免时机**:测试正是要测试登录流程本身时。
|
||
|
||
在 setup 项目中生成一次存储状态,然后在所有需要认证的测试中复用。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
projects: [
|
||
{
|
||
name: "auth-setup",
|
||
testMatch: /auth\.setup\.ts/,
|
||
},
|
||
{
|
||
name: "authenticated-tests",
|
||
dependencies: ["auth-setup"],
|
||
use: {
|
||
storageState: ".auth/user.json",
|
||
},
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// tests/auth.setup.ts
|
||
import { test as setup, expect } from "@playwright/test"
|
||
import path from "node:path"
|
||
|
||
const authFile = path.join(__dirname, "..", ".auth", "user.json")
|
||
|
||
setup("以标准用户身份认证", async ({ page }) => {
|
||
await page.goto("/login")
|
||
await page.getByLabel("邮箱").fill(process.env.TEST_USER_EMAIL!)
|
||
await page.getByLabel("密码").fill(process.env.TEST_USER_PASSWORD!)
|
||
await page.getByRole("button", { name: "登录" }).click()
|
||
|
||
await expect(page.getByRole("heading", { name: "控制面板" })).toBeVisible()
|
||
|
||
await page.context().storageState({ path: authFile })
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
projects: [
|
||
{
|
||
name: "auth-setup",
|
||
testMatch: /auth\.setup\.js/,
|
||
},
|
||
{
|
||
name: "authenticated-tests",
|
||
dependencies: ["auth-setup"],
|
||
use: {
|
||
storageState: ".auth/user.json",
|
||
},
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
```javascript
|
||
// tests/auth.setup.js
|
||
const { test: setup, expect } = require("@playwright/test")
|
||
const path = require("node:path")
|
||
|
||
const authFile = path.join(__dirname, "..", ".auth", "user.json")
|
||
|
||
setup("以标准用户身份认证", async ({ page }) => {
|
||
await page.goto("/login")
|
||
await page.getByLabel("邮箱").fill(process.env.TEST_USER_EMAIL)
|
||
await page.getByLabel("密码").fill(process.env.TEST_USER_PASSWORD)
|
||
await page.getByRole("button", { name: "登录" }).click()
|
||
|
||
await expect(page.getByRole("heading", { name: "控制面板" })).toBeVisible()
|
||
|
||
await page.context().storageState({ path: authFile })
|
||
})
|
||
```
|
||
|
||
对于多个角色,创建独立的 setup 文件和存储状态文件:
|
||
|
||
```typescript
|
||
// tests/admin-auth.setup.ts
|
||
import { test as setup } from "@playwright/test"
|
||
import path from "node:path"
|
||
|
||
const adminAuthFile = path.join(__dirname, "..", ".auth", "admin.json")
|
||
|
||
setup("以管理员身份认证", async ({ page }) => {
|
||
await page.goto("/login")
|
||
await page.getByLabel("邮箱").fill(process.env.ADMIN_EMAIL!)
|
||
await page.getByLabel("密码").fill(process.env.ADMIN_PASSWORD!)
|
||
await page.getByRole("button", { name: "登录" }).click()
|
||
await page.context().storageState({ path: adminAuthFile })
|
||
})
|
||
```
|
||
|
||
将 `.auth/` 添加到 `.gitignore`——存储状态文件包含会话令牌。
|
||
|
||
---
|
||
|
||
### 测试数据清理
|
||
|
||
**使用时机**:始终如此。每个创建数据的测试都必须清理数据。
|
||
|
||
**避免时机**:没有。跳过清理会导致后续运行中出现级联失败。
|
||
|
||
**策略 1:Fixture 拆卸(推荐)**
|
||
|
||
将清理代码放在 fixture 的拆卸块中。即使测试抛出异常也会执行。
|
||
|
||
```typescript
|
||
// 已在 API 种子数据中展示——清理在 use() 之后运行
|
||
apiUser: async ({ request }, use) => {
|
||
const user = await createViaApi(request);
|
||
await use(user);
|
||
// 这段代码始终运行——即使测试失败
|
||
await request.delete(`/api/users/${user.id}`);
|
||
},
|
||
```
|
||
|
||
**策略 2:按时间戳前缀批量清理**
|
||
|
||
用可识别的前缀标记所有测试创建的数据,然后在全局拆卸中清扫。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// global-teardown.ts
|
||
import { request } from "@playwright/test"
|
||
|
||
export default async function globalTeardown() {
|
||
const context = await request.newContext({
|
||
baseURL: process.env.BASE_URL,
|
||
})
|
||
|
||
// 删除本次运行中创建的所有测试实体
|
||
// 假设实体使用了 "test-" 前缀创建
|
||
const response = await context.delete("/api/test-data/cleanup", {
|
||
data: { prefix: "test-", olderThanMinutes: 60 },
|
||
})
|
||
|
||
if (!response.ok()) {
|
||
console.warn(`清理返回了 ${response.status()}`)
|
||
}
|
||
|
||
await context.dispose()
|
||
}
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// global-teardown.js
|
||
const { request } = require("@playwright/test")
|
||
|
||
module.exports = async function globalTeardown() {
|
||
const context = await request.newContext({
|
||
baseURL: process.env.BASE_URL,
|
||
})
|
||
|
||
const response = await context.delete("/api/test-data/cleanup", {
|
||
data: { prefix: "test-", olderThanMinutes: 60 },
|
||
})
|
||
|
||
if (!response.ok()) {
|
||
console.warn(`清理返回了 ${response.status()}`)
|
||
}
|
||
|
||
await context.dispose()
|
||
}
|
||
```
|
||
|
||
**策略 3:每个 Worker 独立租户**
|
||
|
||
每个 Playwright worker 获得自己的租户/组织。所有数据都限定在该租户范围内。拆卸时删除整个租户。
|
||
|
||
```typescript
|
||
// tests/fixtures/tenant.fixture.ts
|
||
import { test as base } from "@playwright/test"
|
||
|
||
export const test = base.extend<{}, { workerTenant: { id: string; apiKey: string } }>({
|
||
workerTenant: [
|
||
async ({ request }, use) => {
|
||
const res = await request.post("/api/tenants", {
|
||
data: { name: `test-worker-${Date.now()}` },
|
||
})
|
||
const tenant = await res.json()
|
||
|
||
await use(tenant)
|
||
|
||
await request.delete(`/api/tenants/${tenant.id}`)
|
||
},
|
||
{ scope: "worker" },
|
||
],
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 环境特定数据
|
||
|
||
**使用时机**:测试在多个环境(dev、staging、production-mirror)中运行,这些环境具有不同的 base URL、凭据或数据约束时。
|
||
|
||
**避免时机**:只有一个测试环境时。
|
||
|
||
永远不要在测试文件中硬编码环境特定的值。使用 `.env` 文件和 `playwright.config` 注入它们。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
import dotenv from "dotenv"
|
||
import path from "node:path"
|
||
|
||
// 加载环境特定的 .env 文件
|
||
const envFile = process.env.TEST_ENV || "local"
|
||
dotenv.config({ path: path.resolve(__dirname, `.env.${envFile}`) })
|
||
|
||
export default defineConfig({
|
||
use: {
|
||
baseURL: process.env.BASE_URL,
|
||
},
|
||
})
|
||
```
|
||
|
||
```
|
||
# .env.local
|
||
BASE_URL=http://localhost:3000
|
||
TEST_USER_EMAIL=testuser@localhost.test
|
||
TEST_USER_PASSWORD=localpassword123
|
||
|
||
# .env.staging
|
||
BASE_URL=https://staging.example.com
|
||
TEST_USER_EMAIL=e2e-bot@staging.example.com
|
||
TEST_USER_PASSWORD=staging-secret-from-vault
|
||
```
|
||
|
||
```typescript
|
||
// tests/fixtures/env-data.fixture.ts
|
||
import { test as base } from "@playwright/test"
|
||
|
||
type EnvConfig = {
|
||
testCredentials: { email: string; password: string }
|
||
}
|
||
|
||
export const test = base.extend<EnvConfig>({
|
||
testCredentials: async ({}, use) => {
|
||
const email = process.env.TEST_USER_EMAIL
|
||
const password = process.env.TEST_USER_PASSWORD
|
||
if (!email || !password) {
|
||
throw new Error("必须设置 TEST_USER_EMAIL 和 TEST_USER_PASSWORD")
|
||
}
|
||
await use({ email, password })
|
||
},
|
||
})
|
||
|
||
export { expect } from "@playwright/test"
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
const dotenv = require("dotenv")
|
||
const path = require("node:path")
|
||
|
||
const envFile = process.env.TEST_ENV || "local"
|
||
dotenv.config({ path: path.resolve(__dirname, `.env.${envFile}`) })
|
||
|
||
module.exports = defineConfig({
|
||
use: {
|
||
baseURL: process.env.BASE_URL,
|
||
},
|
||
})
|
||
```
|
||
|
||
针对特定环境运行:
|
||
|
||
```bash
|
||
TEST_ENV=staging npx playwright test
|
||
```
|
||
|
||
---
|
||
|
||
### 测试数据的 Fixtures
|
||
|
||
**使用时机**:测试数据的设置和拆卸应该被封装、可复用,并保证清理时。这是所有非平凡测试数据的推荐模式。
|
||
|
||
**避免时机**:数据是一个简单的内联值,不需要清理时。
|
||
|
||
Fixtures 是 Playwright 中可靠测试数据管理的基石。它们可组合、保证拆卸,并使测试具有声明性。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// tests/fixtures/index.ts
|
||
import { test as base, expect } from "@playwright/test"
|
||
import { createUserData, UserData } from "../factories/user.factory"
|
||
|
||
type TestFixtures = {
|
||
seedUser: UserData & { id: string }
|
||
seedProduct: { id: string; name: string; price: number }
|
||
}
|
||
|
||
export const test = base.extend<TestFixtures>({
|
||
seedUser: async ({ request }, use) => {
|
||
const data = createUserData()
|
||
const res = await request.post("/api/users", { data })
|
||
expect(res.ok()).toBeTruthy()
|
||
const user = { ...data, id: (await res.json()).id }
|
||
|
||
await use(user)
|
||
|
||
await request.delete(`/api/users/${user.id}`)
|
||
},
|
||
|
||
seedProduct: async ({ request }, use) => {
|
||
const data = {
|
||
name: `Product-${Date.now()}`,
|
||
price: 49.99,
|
||
category: "Testing",
|
||
}
|
||
const res = await request.post("/api/products", { data })
|
||
expect(res.ok()).toBeTruthy()
|
||
const product = { ...data, id: (await res.json()).id }
|
||
|
||
await use(product)
|
||
|
||
await request.delete(`/api/products/${product.id}`)
|
||
},
|
||
})
|
||
|
||
export { expect }
|
||
```
|
||
|
||
```typescript
|
||
// tests/shopping-cart.spec.ts
|
||
import { test, expect } from "./fixtures"
|
||
|
||
test("将商品添加到购物车", async ({ page, seedUser, seedProduct }) => {
|
||
// seedUser 和 seedProduct 已经创建好,并且会自动清理
|
||
await page.goto(`/products/${seedProduct.id}`)
|
||
await page.getByRole("button", { name: "加入购物车" }).click()
|
||
|
||
await page.goto("/cart")
|
||
await expect(page.getByText(seedProduct.name)).toBeVisible()
|
||
await expect(page.getByText("$49.99")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// tests/fixtures/index.js
|
||
const { test: base, expect } = require("@playwright/test")
|
||
const { createUserData } = require("../factories/user.factory")
|
||
|
||
const test = base.extend({
|
||
seedUser: async ({ request }, use) => {
|
||
const data = createUserData()
|
||
const res = await request.post("/api/users", { data })
|
||
expect(res.ok()).toBeTruthy()
|
||
const user = { ...data, id: (await res.json()).id }
|
||
|
||
await use(user)
|
||
|
||
await request.delete(`/api/users/${user.id}`)
|
||
},
|
||
|
||
seedProduct: async ({ request }, use) => {
|
||
const data = {
|
||
name: `Product-${Date.now()}`,
|
||
price: 49.99,
|
||
category: "Testing",
|
||
}
|
||
const res = await request.post("/api/products", { data })
|
||
expect(res.ok()).toBeTruthy()
|
||
const product = { ...data, id: (await res.json()).id }
|
||
|
||
await use(product)
|
||
|
||
await request.delete(`/api/products/${product.id}`)
|
||
},
|
||
})
|
||
|
||
module.exports = { test, expect }
|
||
```
|
||
|
||
```javascript
|
||
// tests/shopping-cart.spec.js
|
||
const { test, expect } = require("./fixtures")
|
||
|
||
test("将商品添加到购物车", async ({ page, seedUser, seedProduct }) => {
|
||
await page.goto(`/products/${seedProduct.id}`)
|
||
await page.getByRole("button", { name: "加入购物车" }).click()
|
||
|
||
await page.goto("/cart")
|
||
await expect(page.getByText(seedProduct.name)).toBeVisible()
|
||
await expect(page.getByText("$49.99")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
Fixtures 仅在测试按名称请求时才会运行。如果某个测试不使用 `seedProduct`,则商品永远不会被创建——没有浪费的设置,没有不必要的清理。
|
||
|
||
## 决策指南
|
||
|
||
```
|
||
你的测试需要什么数据?
|
||
│
|
||
├── 简单值(表单字段的字符串、数字)?
|
||
│ └── 使用内联数据——保持在测试内部
|
||
│
|
||
├── 相同形状在多个测试中使用?
|
||
│ ├── 字段 < 5 个?→ 使用工厂函数
|
||
│ └── 字段 >= 5 个且有可选/条件字段?→ 使用 Builder
|
||
│
|
||
├── 需要看起来逼真的数据(姓名、地址)?
|
||
│ └── 使用 Faker 并设置固定种子
|
||
│
|
||
├── 测试开始前必须存在的实体?
|
||
│ ├── 应用有该实体的 API?
|
||
│ │ └── 在 fixture 中使用 API 种子数据(推荐)
|
||
│ ├── 没有 API,但有数据库访问?
|
||
│ │ └── 在 fixture 中使用数据库种子数据
|
||
│ └── 没有 API,也没有数据库访问?
|
||
│ └── 在 beforeEach 中使用 UI 设置(最慢——尽可能避免)
|
||
│
|
||
├── 需要已认证的会话?
|
||
│ └── 通过 setup 项目使用存储状态
|
||
│
|
||
└── 需要每个 Worker 隔离的数据?
|
||
└── 使用 Worker 作用域的 Fixtures 配合租户隔离
|
||
```
|
||
|
||
### 速度排名(从最快到最慢)
|
||
|
||
1. 内联数据 / 工厂函数 / Faker——无 I/O,即时
|
||
2. API 种子数据——每个实体一次 HTTP 调用
|
||
3. 数据库种子数据——直接数据库调用,跳过应用逻辑
|
||
4. 存储状态——一次性登录,跨测试复用
|
||
5. 基于 UI 的设置——每个测试完整的浏览器交互(避免)
|
||
|
||
### 隔离性排名(从最高到最低)
|
||
|
||
1. 测试作用域的 fixtures 配合 API 拆卸——每个测试获得新数据,使用后清理
|
||
2. Worker 作用域的租户隔离——同一 Worker 中的测试共享一个租户,Worker 退出时清理
|
||
3. 时间戳前缀批量清理——捕获孤立数据,在全局拆卸中运行
|
||
4. 共享数据库状态——脆弱,容易因执行顺序产生问题(避免)
|
||
|
||
## 反模式
|
||
|
||
### 测试间共享可变数据
|
||
|
||
```typescript
|
||
// 错误——测试共享同一个用户对象,依赖执行顺序
|
||
let sharedUser: UserData
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
sharedUser = await createUser(request)
|
||
})
|
||
|
||
test("更新用户姓名", async ({ page }) => {
|
||
// 修改了 sharedUser——其他测试会看到已更改的状态
|
||
})
|
||
|
||
test("检查原始姓名", async ({ page }) => {
|
||
// 失败,因为前一个测试更改了姓名
|
||
})
|
||
```
|
||
|
||
修复:使用测试作用域的 fixtures。每个测试获得自己的实例。
|
||
|
||
### 硬编码的 ID
|
||
|
||
```typescript
|
||
// 错误——这个 ID 只存在于你的本地数据库中
|
||
test("编辑商品", async ({ page }) => {
|
||
await page.goto("/products/507f1f77bcf86cd799439011/edit")
|
||
})
|
||
```
|
||
|
||
修复:在 fixture 中创建商品,并使用返回的 ID。
|
||
|
||
### 无清理
|
||
|
||
```typescript
|
||
// 错误——创建数据但从不删除
|
||
test.beforeEach(async ({ request }) => {
|
||
await request.post("/api/products", { data: { name: "泄漏的商品" } })
|
||
})
|
||
// 随着时间的推移,数据库中堆满测试残留,导致速度变慢和误报
|
||
```
|
||
|
||
修复:始终将创建与删除配对在 fixture 拆卸中。
|
||
|
||
### 依赖预先存在的数据库状态
|
||
|
||
```typescript
|
||
// 错误——假设"高级计划"已存在于数据库中
|
||
test("订阅高级计划", async ({ page }) => {
|
||
await page.goto("/pricing")
|
||
await page.getByRole("button", { name: "高级计划" }).click()
|
||
// 在全新数据库或不同环境中会失败
|
||
})
|
||
```
|
||
|
||
修复:在测试前通过 API 或数据库 fixture 写入计划种子数据。
|
||
|
||
### 在测试中使用生产数据
|
||
|
||
```typescript
|
||
// 错误——针对真实客户数据进行测试
|
||
test("查看客户订单", async ({ page }) => {
|
||
await page.goto("/admin/customers/real-customer-id/orders")
|
||
})
|
||
```
|
||
|
||
修复:创建合成测试数据。永远不要将测试套件指向生产数据库或使用真实客户标识符。
|
||
|
||
### 过度设计数据设置
|
||
|
||
```typescript
|
||
// 错误——为抽象而抽象
|
||
const data = new TestDataOrchestrator()
|
||
.withStrategy("api")
|
||
.withRetry(3)
|
||
.withCleanupPolicy("deferred")
|
||
.withEnvironment("staging")
|
||
.build()
|
||
```
|
||
|
||
修复:一个工厂函数加一个 fixture 覆盖了 95% 的情况。只有在确认有必要时才增加复杂度。
|
||
|
||
## 故障排查
|
||
|
||
| 问题 | 原因 | 修复 |
|
||
| ----------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||
| 测试失败并出现"重复键"错误 | 之前运行的数据未清理 | 添加 fixture 拆卸;在 `globalTeardown` 中运行批量清理 |
|
||
| 单独运行时通过,并行时失败 | 测试共享可变状态或在唯一字段上冲突 | 在工厂输出中使用 `Date.now()` 或 `crypto.randomUUID()`;使用测试作用域的 fixtures |
|
||
| Faker 在重试时产生不同的数据 | Faker 未设置种子,或种子在运行间变化 | 使用 `testInfo.workerIndex` 或固定值作为种子 |
|
||
| 存储状态过期/会话无效 | 认证令牌 TTL 很短 | 在每个套件前重新运行认证 setup;如果需要,对依赖认证的项目设置 `fullyParallel: false` |
|
||
| 清理失败并阻塞其他测试 | 拆卸过程进行网络调用可能超时 | 将清理包装在 `try/catch` 中;记录失败但不抛出异常;依赖批量清理作为安全网 |
|
||
| 数据库种子数据很慢 | 过多的单个 INSERT 语句 | 批量插入;使用事务;考虑改用 API 种子数据 |
|
||
| 针对 staging 运行时测试失败 | 硬编码的值仅存在于本地 | 对所有环境特定数据使用环境变量;在 fixture 中验证并给出清晰的错误信息 |
|
||
|
||
### 使清理更具韧性
|
||
|
||
```typescript
|
||
// 将 fixture 拆卸包装在 try/catch 中,这样清理失败不会掩盖真实的测试失败
|
||
seedUser: async ({ request }, use) => {
|
||
const user = await createViaApi(request);
|
||
await use(user);
|
||
|
||
try {
|
||
await request.delete(`/api/users/${user.id}`);
|
||
} catch (error) {
|
||
console.warn(`清理用户 ${user.id} 失败:`, error);
|
||
}
|
||
},
|
||
```
|
||
|
||
## 相关文档
|
||
|
||
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) — fixture 机制、作用域、组合
|
||
- [core/authentication.md](authentication.md) — 存储状态设置、多角色认证模式
|
||
- [core/api-testing.md](api-testing.md) — API 请求上下文、响应验证
|
||
- [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) — 批量操作的全局设置/拆卸
|
||
- [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md) — 何时使用 fixtures vs 页面对象 vs 辅助函数
|