项目文件夹

文件
2026-07-13 21:36:47 +08:00

1234 行
36 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 测试数据管理
> **使用时机**:每个需要与数据交互的测试——用户账户、表单输入、实体,或任何在断言运行前必须存在的状态。
## 快速参考
| 策略 | 速度 | 隔离性 | 复杂度 | 最佳适用场景 |
| ----------------- | -------- | -------- | ------ | --------------------------------------- |
| 内联数据 | 即时 | 完美 | 无 | 简单的值检查、表单输入 |
| 工厂函数 | 即时 | 完美 | 低 | 唯一标识符、一致的形状 |
| 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`——存储状态文件包含会话令牌。
---
### 测试数据清理
**使用时机**:始终如此。每个创建数据的测试都必须清理数据。
**避免时机**:没有。跳过清理会导致后续运行中出现级联失败。
**策略 1Fixture 拆卸(推荐)**
将清理代码放在 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 辅助函数