36 KiB
测试数据管理
使用时机:每个需要与数据交互的测试——用户账户、表单输入、实体,或任何在断言运行前必须存在的状态。
快速参考
| 策略 | 速度 | 隔离性 | 复杂度 | 最佳适用场景 |
|---|---|---|---|---|
| 内联数据 | 即时 | 完美 | 无 | 简单的值检查、表单输入 |
| 工厂函数 | 即时 | 完美 | 低 | 唯一标识符、一致的形状 |
| Faker/随机数据 | 即时 | 完美 | 低 | 逼真的字段、边界情况发现 |
| Builder 模式 | 即时 | 完美 | 中 | 含多个可选字段的复杂对象 |
| API 种子数据 | 快 | 完美 | 中 | 创建应用依赖的实体 |
| 数据库种子数据 | 快 | 良好 | 高 | 复杂关系型数据、批量设置 |
| 存储状态 | 快 | 良好 | 低 | 复用已认证的会话 |
| 基于 Fixture 的设置 | 快 | 完美 | 中 | 封装设置 + 保证清理 |
核心原则:每个测试创建自己的数据,并在完成后自行清理。没有测试应依赖于另一个测试留下的数据或环境中预先存在的数据。
模式
内联测试数据
使用时机:数据简单、仅用于一个测试,并且对理解断言至关重要时。
避免时机:相同的数据形状在多个测试中重复出现时——此时应提取为工厂函数。
内联数据让测试自文档化。读者一眼就能看到什么重要,无需去追踪 import。
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
// 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
// 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,
}
}
// 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
// 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 }
// 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
// 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(),
},
}
}
// 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
// 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 }
// 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
// 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 }
}
}
// 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
// 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 }
// 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
// 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"
// 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
// 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 }
// 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
// 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
// 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
// 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
// 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",
},
},
],
})
// 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
// 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",
},
},
],
})
// 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 文件和存储状态文件:
// 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 的拆卸块中。即使测试抛出异常也会执行。
// 已在 API 种子数据中展示——清理在 use() 之后运行
apiUser: async ({ request }, use) => {
const user = await createViaApi(request);
await use(user);
// 这段代码始终运行——即使测试失败
await request.delete(`/api/users/${user.id}`);
},
策略 2:按时间戳前缀批量清理
用可识别的前缀标记所有测试创建的数据,然后在全局拆卸中清扫。
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
// 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 获得自己的租户/组织。所有数据都限定在该租户范围内。拆卸时删除整个租户。
// 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
// 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
// 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
// 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,
},
})
针对特定环境运行:
TEST_ENV=staging npx playwright test
测试数据的 Fixtures
使用时机:测试数据的设置和拆卸应该被封装、可复用,并保证清理时。这是所有非平凡测试数据的推荐模式。
避免时机:数据是一个简单的内联值,不需要清理时。
Fixtures 是 Playwright 中可靠测试数据管理的基石。它们可组合、保证拆卸,并使测试具有声明性。
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 }
// 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
// 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 }
// 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 配合租户隔离
速度排名(从最快到最慢)
- 内联数据 / 工厂函数 / Faker——无 I/O,即时
- API 种子数据——每个实体一次 HTTP 调用
- 数据库种子数据——直接数据库调用,跳过应用逻辑
- 存储状态——一次性登录,跨测试复用
- 基于 UI 的设置——每个测试完整的浏览器交互(避免)
隔离性排名(从最高到最低)
- 测试作用域的 fixtures 配合 API 拆卸——每个测试获得新数据,使用后清理
- Worker 作用域的租户隔离——同一 Worker 中的测试共享一个租户,Worker 退出时清理
- 时间戳前缀批量清理——捕获孤立数据,在全局拆卸中运行
- 共享数据库状态——脆弱,容易因执行顺序产生问题(避免)
反模式
测试间共享可变数据
// 错误——测试共享同一个用户对象,依赖执行顺序
let sharedUser: UserData
test.beforeAll(async ({ request }) => {
sharedUser = await createUser(request)
})
test("更新用户姓名", async ({ page }) => {
// 修改了 sharedUser——其他测试会看到已更改的状态
})
test("检查原始姓名", async ({ page }) => {
// 失败,因为前一个测试更改了姓名
})
修复:使用测试作用域的 fixtures。每个测试获得自己的实例。
硬编码的 ID
// 错误——这个 ID 只存在于你的本地数据库中
test("编辑商品", async ({ page }) => {
await page.goto("/products/507f1f77bcf86cd799439011/edit")
})
修复:在 fixture 中创建商品,并使用返回的 ID。
无清理
// 错误——创建数据但从不删除
test.beforeEach(async ({ request }) => {
await request.post("/api/products", { data: { name: "泄漏的商品" } })
})
// 随着时间的推移,数据库中堆满测试残留,导致速度变慢和误报
修复:始终将创建与删除配对在 fixture 拆卸中。
依赖预先存在的数据库状态
// 错误——假设"高级计划"已存在于数据库中
test("订阅高级计划", async ({ page }) => {
await page.goto("/pricing")
await page.getByRole("button", { name: "高级计划" }).click()
// 在全新数据库或不同环境中会失败
})
修复:在测试前通过 API 或数据库 fixture 写入计划种子数据。
在测试中使用生产数据
// 错误——针对真实客户数据进行测试
test("查看客户订单", async ({ page }) => {
await page.goto("/admin/customers/real-customer-id/orders")
})
修复:创建合成测试数据。永远不要将测试套件指向生产数据库或使用真实客户标识符。
过度设计数据设置
// 错误——为抽象而抽象
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 中验证并给出清晰的错误信息 |
使清理更具韧性
// 将 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 — fixture 机制、作用域、组合
- core/authentication.md — 存储状态设置、多角色认证模式
- core/api-testing.md — API 请求上下文、响应验证
- ci/global-setup-teardown.md — 批量操作的全局设置/拆卸
- pom/pom-vs-fixtures-vs-helpers.md — 何时使用 fixtures vs 页面对象 vs 辅助函数