# 测试数据管理 > **使用时机**:每个需要与数据交互的测试——用户账户、表单输入、实体,或任何在断言运行前必须存在的状态。 ## 快速参考 | 策略 | 速度 | 隔离性 | 复杂度 | 最佳适用场景 | | ----------------- | -------- | -------- | ------ | --------------------------------------- | | 内联数据 | 即时 | 完美 | 无 | 简单的值检查、表单输入 | | 工厂函数 | 即时 | 完美 | 低 | 唯一标识符、一致的形状 | | 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 { 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({ 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({ 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({ 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({ 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 辅助函数