Files
2026-07-13 21:36:47 +08:00

36 KiB
Raw Permalink Blame History

测试数据管理

使用时机:每个需要与数据交互的测试——用户账户、表单输入、实体,或任何在断言运行前必须存在的状态。

快速参考

策略 速度 隔离性 复杂度 最佳适用场景
内联数据 即时 完美 简单的值检查、表单输入
工厂函数 即时 完美 唯一标识符、一致的形状
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——存储状态文件包含会话令牌。


测试数据清理

使用时机:始终如此。每个创建数据的测试都必须清理数据。

避免时机:没有。跳过清理会导致后续运行中出现级联失败。

策略 1Fixture 拆卸(推荐)

将清理代码放在 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 配合租户隔离

速度排名(从最快到最慢)

  1. 内联数据 / 工厂函数 / Faker——无 I/O,即时
  2. API 种子数据——每个实体一次 HTTP 调用
  3. 数据库种子数据——直接数据库调用,跳过应用逻辑
  4. 存储状态——一次性登录,跨测试复用
  5. 基于 UI 的设置——每个测试完整的浏览器交互(避免)

隔离性排名(从最高到最低)

  1. 测试作用域的 fixtures 配合 API 拆卸——每个测试获得新数据,使用后清理
  2. Worker 作用域的租户隔离——同一 Worker 中的测试共享一个租户,Worker 退出时清理
  3. 时间戳前缀批量清理——捕获孤立数据,在全局拆卸中运行
  4. 共享数据库状态——脆弱,容易因执行顺序产生问题(避免)

反模式

测试间共享可变数据

// 错误——测试共享同一个用户对象,依赖执行顺序
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);
  }
},

相关文档