Sanity Studio 端到端测试数据管理:Playwright 测试数据工厂与生成器完整实战指南

原创2026-09-16 14:35:201,712 阅读
文章标签:CMS前端

Sanity Studio 端到端测试数据管理:Playwright 测试数据工厂与生成器完整实战指南

导读

测试数据的组织方式直接决定 Playwright 端到端测试的稳定性、可维护性与可复现性。本文以 .agents/skills/playwright-best-practices/core/test-data.md 为骨架,系统讲解测试数据工厂(Factory Pattern)、Faker 数据生成器、数据驱动测试(Data-Driven Testing)、Fixture 数据注入与数据库播种(Seeding)等核心模式,并结合 Sanity Studio 仓库 e2e/ 目录的真实实现(如 createUniqueDocument、studio-test.ts 自定义 fixture)展示这些模式在大型开源项目中的落地方式。读完本文,你将掌握一套从"随手硬编码"到"可复用、可复现、可清理"的完整测试数据治理方案。

本文属于 playwright-best-practices 技能包中的核心章节,姊妹篇包括 fixtures-hooks.md(每测试数据库夹具、事务回滚)与 global-setup.md(一次性数据库初始化、迁移与快照)。

一、Factory Pattern:可复用测试数据构建器

工厂(Factory)是测试数据治理的基石。它把"如何构造一个合法对象"的逻辑收敛到一处,测试代码只表达"我想要什么样的数据",而不是重复拼装。

1.1 基础工厂(Basic Factory)

最朴素的工厂是一个带计数器与 overrides 合并能力的纯函数。计数器保证每次调用生成的 ID 与邮箱唯一,避免测试间数据冲突:

// factories/user.factory.ts
interface User {
  id: string
  email: string
  name: string
  role: 'admin' | 'user' | 'guest'
  createdAt: Date
}

let userIdCounter = 0

export function createUser(overrides: Partial<User> = {}): User {
  userIdCounter++
  return {
    id: `user-${userIdCounter}`,
    email: `user${userIdCounter}@test.com`,
    name: `Test User ${userIdCounter}`,
    role: 'user',
    createdAt: new Date(),
    ...overrides,
  }
}

// Usage
const user = createUser()
const admin = createUser({role: 'admin', name: 'Admin User'})

核心设计要点:

  • 默认值兜底:未指定字段时返回确定性默认值(role: 'user');
  • 覆盖优先:...overrides 置于对象末尾,调用方传入的字段覆盖默认值;
  • 可读性:createUser({role: 'admin'}) 比散落各处的字面量对象更自解释。

1.2 带特征的工厂(Factory with Traits)

当业务存在多组"高频组合态"(如缺货、特价、精选)时,可把组合预定义为 Trait,用可变参数拼装:

// factories/product.factory.ts
interface Product {
  id: string
  name: string
  price: number
  stock: number
  category: string
  featured: boolean
}

type ProductTrait = 'outOfStock' | 'featured' | 'expensive' | 'sale'

const traits: Record<ProductTrait, Partial<Product>> = {
  outOfStock: {stock: 0},
  featured: {featured: true},
  expensive: {price: 999.99},
  sale: {price: 9.99},
}

let productIdCounter = 0

export function createProduct(
  overrides: Partial<Product> = {},
  ...traitNames: ProductTrait[]
): Product {
  productIdCounter++

  const appliedTraits = traitNames.reduce((acc, trait) => ({...acc, ...traits[trait]}), {})

  return {
    id: `prod-${productIdCounter}`,
    name: `Product ${productIdCounter}`,
    price: 29.99,
    stock: 100,
    category: 'General',
    featured: false,
    ...appliedTraits,
    ...overrides,
  }
}

// Usage
const product = createProduct()
const featuredProduct = createProduct({}, 'featured')
const saleItem = createProduct({name: 'Sale Item'}, 'sale', 'featured')
const soldOut = createProduct({}, 'outOfStock')

合并优先级清晰:默认值 < Trait < 显式 overrides。Trait 通过 reduce 逐一叠加,多个 Trait 冲突时后传入者覆盖前者;显式 overrides 永远拥有最终决定权。

1.3 带关联关系的工厂(Factory with Relationships)

真实业务对象往往相互引用(订单→用户→商品)。工厂可以互相调用、级联构造,并基于子对象自动推导派生字段(如订单总价):

// factories/order.factory.ts
import {createUser, User} from './user.factory'
import {createProduct, Product} from './product.factory'

interface OrderItem {
  product: Product
  quantity: number
}

interface Order {
  id: string
  user: User
  items: OrderItem[]
  total: number
  status: 'pending' | 'paid' | 'shipped' | 'delivered'
}

let orderIdCounter = 0

export function createOrder(overrides: Partial<Order> = {}): Order {
  orderIdCounter++

  const user = overrides.user ?? createUser()
  const items = overrides.items ?? [{product: createProduct(), quantity: 1}]
  const total = items.reduce((sum, item) => sum + item.product.price * item.quantity, 0)

  return {
    id: `order-${orderIdCounter}`,
    user,
    items,
    total,
    status: 'pending',
    ...overrides,
  }
}

// Usage
const order = createOrder()
const bigOrder = createOrder({
  items: [
    {product: createProduct({price: 100}), quantity: 5},
    {product: createProduct({price: 50}), quantity: 2},
  ],
})

注意 total 由 items 推导:调用方传入 items 后总价自动重算,避免"数据自相矛盾"这类测试陷阱。这正是工厂优于手工拼装的关键——派生字段保持一致性。

二、Faker Integration:真实感数据与可复现性

手工工厂的默认值偏"假",当被测 UI 对数据格式敏感(如邮箱格式、地址校验)时,引入 Faker 生成贴近真实分布的数据更有效。

2.1 安装与基础使用

npm install -D @faker-js/faker
// factories/faker-user.factory.ts
import {faker} from '@faker-js/faker'

interface User {
  id: string
  email: string
  name: string
  avatar: string
  address: {
    street: string
    city: string
    country: string
    zipCode: string
  }
}

export function createFakeUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    avatar: faker.image.avatar(),
    address: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      country: faker.location.country(),
      zipCode: faker.location.zipCode(),
    },
    ...overrides,
  }
}

Faker v8+ 的 API 均为模块化命名空间(faker.string、faker.internet、faker.person、faker.location),生成数据贴近真实分布,能更充分地触发前端的校验逻辑。

2.2 种子化 Faker:可复现的随机

随机数据的最大问题是"失败不可复现"。faker.seed() 可让随机序列确定化——同一种子必然产生同一批数据:

import {faker} from '@faker-js/faker'

// Set seed for reproducible data
faker.seed(12345)

export function createDeterministicUser(): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    // Same seed = same data every time
  }
}

// Or seed per test
test('user profile', async ({page}) => {
  faker.seed(42) // Reset seed for this test
  const user = createFakeUser()
  // user will always have the same data
})

推荐做法:在单个测试内显式重置种子(如 faker.seed(42)),既保证该测试的确定性,又不污染其他测试的随机性。

2.3 把 Faker 封装为 Fixture

更进一步,可把"种子化后的 faker"封装为 Playwright fixture,用 testInfo.title 派生种子,让每个测试天然拥有各自稳定的数据序列:

// fixtures/faker.fixture.ts
import {test as base} from '@playwright/test'
import {faker} from '@faker-js/faker'

type FakerFixtures = {
  fake: typeof faker
}

export const test = base.extend<FakerFixtures>({
  fake: async ({}, use, testInfo) => {
    // Seed based on test name for reproducibility
    faker.seed(testInfo.title.length)
    await use(faker)
  },
})

// Usage
test('create user with fake data', async ({page, fake}) => {
  await page.goto('/signup')

  await page.getByLabel('Name').fill(fake.person.fullName())
  await page.getByLabel('Email').fill(fake.internet.email())
  await page.getByLabel('Password').fill(fake.internet.password())

  await page.getByRole('button', {name: 'Sign Up'}).click()
})

该模式将"种子策略"集中管理,测试文件内直接消费 fake 参数,无需关心种子逻辑,同时保证同一测试重跑时数据完全一致。

三、Data-Driven Testing:数据与用例分离

当同一流程需覆盖多组输入/期望时,数据驱动测试把"数据"与"用例逻辑"解耦,一份逻辑跑遍所有场景。

3.1 test.each / 数组遍历

Playwright 的 test 可以在 for...of 循环中动态注册测试,用例标题中嵌入数据便于定位失败项:

const loginScenarios = [
  {email: 'user@example.com', password: 'pass123', expected: 'Dashboard'},
  {email: 'admin@example.com', password: 'admin123', expected: 'Admin Panel'},
  {
    email: 'invalid@example.com',
    password: 'wrong',
    expected: 'Invalid credentials',
  },
]

for (const {email, password, expected} of loginScenarios) {
  test(`login with ${email}`, async ({page}) => {
    await page.goto('/login')
    await page.getByLabel('Email').fill(email)
    await page.getByLabel('Password').fill(password)
    await page.getByRole('button', {name: 'Sign In'}).click()

    await expect(page.getByText(expected)).toBeVisible()
  })
}

3.2 参数化场景数据(Scenario Table)

复杂场景建议把数据抽取到独立模块,形成"场景表":

// data/checkout-scenarios.ts
export const checkoutScenarios = [
  {
    name: 'standard shipping',
    shipping: 'standard',
    expectedDays: '5-7 business days',
    expectedCost: '$5.99',
  },
  {
    name: 'express shipping',
    shipping: 'express',
    expectedDays: '2-3 business days',
    expectedCost: '$14.99',
  },
  {
    name: 'overnight shipping',
    shipping: 'overnight',
    expectedDays: 'Next business day',
    expectedCost: '$29.99',
  },
]
import {checkoutScenarios} from './data/checkout-scenarios'

test.describe('shipping options', () => {
  for (const scenario of checkoutScenarios) {
    test(`checkout with ${scenario.name}`, async ({page}) => {
      await page.goto('/checkout')

      await page.getByLabel(scenario.shipping, {exact: false}).check()

      await expect(page.getByText(scenario.expectedDays)).toBeVisible()
      await expect(page.getByText(scenario.expectedCost)).toBeVisible()
    })
  }
})

3.3 CSV/JSON 外部数据源

当用例数量大、需与测试代码解耦时,可从 JSON/CSV 文件加载数据:

import fs from 'fs'

interface TestCase {
  input: string
  expected: string
}

// Load test data from JSON
const testCases: TestCase[] = JSON.parse(fs.readFileSync('./data/search-tests.json', 'utf-8'))

test.describe('search functionality', () => {
  for (const {input, expected} of testCases) {
    test(`search for "${input}"`, async ({page}) => {
      await page.goto('/search')
      await page.getByLabel('Search').fill(input)
      await page.getByLabel('Search').press('Enter')

      await expect(page.getByText(expected)).toBeVisible()
    })
  }
})

提示:外部数据源适合"数据量庞大、由业务方维护"的场景;对少量固定场景,内联数组更直观、类型更安全。

四、Test Data Fixtures:把数据注入与生命周期绑定

Playwright fixture 是测试数据的"水龙头"——通过 base.extend 扩展自定义 fixture,测试只需声明依赖,框架负责创建与销毁。

4.1 工厂 + Fixture 组合

// fixtures/data.fixture.ts
import {test as base} from '@playwright/test'
import {createUser, User} from '../factories/user.factory'
import {createProduct, Product} from '../factories/product.factory'

type DataFixtures = {
  testUser: User
  testProducts: Product[]
}

export const test = base.extend<DataFixtures>({
  testUser: async ({}, use) => {
    const user = createUser({name: 'E2E Test User'})
    await use(user)
  },

  testProducts: async ({}, use) => {
    const products = [
      createProduct({name: 'Test Product 1'}),
      createProduct({name: 'Test Product 2'}),
      createProduct({name: 'Test Product 3'}),
    ]
    await use(products)
  },
})

// Usage
test('add product to cart', async ({page, testUser, testProducts}) => {
  // Mock API with test data
  await page.route('**/api/user', (route) => route.fulfill({json: testUser}))
  await page.route('**/api/products', (route) => route.fulfill({json: testProducts}))

  await page.goto('/products')
  await expect(page.getByText(testProducts[0].name)).toBeVisible()
})

该模式特别适合与 page.route 网络拦截配合:fixture 提供数据、route 负责喂给前端,页面无感知地消费"假接口"。

五、Database Seeding:真实后端数据的创建与清理

对需要真实后端的测试,需在测试内播种数据、测试后清理,保证数据集不被污染。

5.1 基于 API 的播种(API-Based Seeding)

利用 Playwright 的 request fixture 调用测试专用 API 创建资源,并通过共享数组在 teardown 阶段批量清理:

// fixtures/seed.fixture.ts
import {test as base, APIRequestContext} from '@playwright/test'
import {createUser} from '../factories/user.factory'

type SeedFixtures = {
  seedUser: (overrides?: Partial<User>) => Promise<User>
  cleanupUsers: string[]
}

export const test = base.extend<SeedFixtures>({
  cleanupUsers: [],

  seedUser: async ({request, cleanupUsers}, use) => {
    await use(async (overrides = {}) => {
      const userData = createUser(overrides)

      const response = await request.post('/api/test/users', {
        data: userData,
      })
      const user = await response.json()

      cleanupUsers.push(user.id)
      return user
    })
  },

  // Cleanup after test
  cleanupUsers: async ({request}, use) => {
    const userIds: string[] = []
    await use(userIds)

    // Delete all created users
    for (const id of userIds) {
      await request.delete(`/api/test/users/${id}`)
    }
  },
})

// Usage
test('user profile page', async ({page, seedUser}) => {
  const user = await seedUser({name: 'John Doe'})

  await page.goto(`/users/${user.id}`)
  await expect(page.getByText('John Doe')).toBeVisible()
})

关键点:工厂负责"造数据",seedUser 负责"入库并登记 ID",cleanupUsers 在 use() 之后执行删除——Playwright fixture 的 teardown 语义保证清理必然执行。

5.2 事务回滚播种(Transaction Rollback Seeding)

对数据库直连场景,可在事务内播种、结束时整体 ROLLBACK,实现零残留的极致隔离:

// fixtures/db.fixture.ts
export const test = base.extend<{}, {db: DbTransaction}>({
  db: [
    async ({}, use) => {
      const client = await pool.connect()
      await client.query('BEGIN')

      await use({
        query: (sql: string, params?: any[]) => client.query(sql, params),
        seed: async (table: string, data: object) => {
          const keys = Object.keys(data)
          const values = Object.values(data)
          const placeholders = keys.map((_, i) => `$${i + 1}`)

          const result = await client.query(
            `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders.join(', ')})
             RETURNING *`,
            values,
          )
          return result.rows[0]
        },
      })

      await client.query('ROLLBACK')
      client.release()
    },
    {scope: 'test'},
  ],
})

scope: 'test' 表示每个测试独享一个新事务,ROLLBACK 让所有插入自动撤销,无需逐一删除。适合 Postgres 等支持事务的数据库。

六、Sanity Studio 仓库实践:工厂与 Fixture 的真实落地

上述模式并非纸上谈兵。Sanity Studio 仓库的 e2e/ 目录就是一套完整的 Playwright 测试数据治理样板,下面逐一对应印证。

6.1 唯一文档工厂:createUniqueDocument

e2e/helpers/createUniqueDocument.ts 是"基础工厂 + 唯一 ID"思想的直接体现——用 @sanity/uuid 生成全局唯一 _id,再通过 Sanity Client 异步写入:

import {type SanityClient, type SanityDocument, type SanityDocumentStub} from '@sanity/client'
import {uuid} from '@sanity/uuid'

export async function createUniqueDocument(
  client: SanityClient,
  {_type, _id, ...restProps}: SanityDocumentStub,
): Promise<Partial<SanityDocument>> {
  const doc = {
    _type,
    _id: _id || uuid(),
    ...restProps,
  }

  await client.create(doc, {visibility: 'async'})
  return doc
}
  • 与文档工厂的 id: user-${counter} 异曲同工,此处用 uuid() 保证跨 worker、跨并发完全唯一;
  • 保留 _id 覆盖入口(_id || uuid()),供 drafts. 前缀等特殊 ID 场景使用;
  • 返回创建的 doc,供后续断言与清理。

它的消费方 e2e/tests/desk/liveEditDraft.spec.ts 展示了"发布版 + 草稿版"双文档的播种方式:

// create published document
const uniqueDoc = await createUniqueDocument(context.client, {_type: 'playlist'})
const id = uniqueDoc._id!

// create draft document
await createUniqueDocument(context.client, {
  _type: 'playlist',
  _id: `drafts.${id}`,
  name: 'Edited by e2e test runner',
})

await page.goto(`/content/playlist;${id}`)

6.2 数据中心化注册与自动清理:studio-test.ts

e2e/studio-test.ts 是仓库的"超级 base test",集中实现数据生命周期管理:

  • 唯一 ID 注册:_TestSanityContext.getUniqueDocumentId() 用 uuid() 生成 ID 并登记到 documentIds 集合;
  • teardown 批量清理:测试结束后执行 sanityClient.delete({query: '*[_id in $ids]', ...}),一次性删除本测试创建的所有文档——这正是"共享数组 + teardown 清理"模式的 Sanity 版本;
  • 数据中心化:sanityClient fixture 统一注入项目 ID、数据集、Token 等环境配置(来自 SANITY_E2E_PROJECT_ID、SANITY_E2E_DATASET、SANITY_E2E_SESSION_TOKEN);
  • 导航工厂:createDraftDocument(navigationPath) 通过 page.goto 进入新建文档页并轮询 data-read-only 属性,确保表单真正可编辑后才返回,避免了"草稿刚创建瞬间仍是只读"的竞态。

6.3 Fixture 场景化扩展:copyPasteFixture.ts 与 array-capabilities.spec.ts

  • e2e/tests/fixtures/copyPasteFixture.ts 展示了"测试即服务":通过 base.extend 重写 page fixture,用 addInitScript 注入剪贴板 mock,并对外暴露 setClipboardItems、getClipboardItemsAsText 等操作型 fixture——测试只需声明参数,即可获得稳定的剪贴板控制能力;
  • e2e/tests/inputs/array-capabilities.spec.ts 展示了"每测试独享数据 + 显式清理":在 testDoc fixture 内用 sanityClient.create 播种引用文档与主文档,fnUse(testDoc) 后立即 delete 两者,保证数据集不被后续测试污染。

七、必须规避的反模式(Anti-Patterns)

反模式 问题 解决方案
硬编码测试数据(Hardcoded test data) 脆弱、大量重复 使用工厂(Factory)
无种子的随机数据(Random data without seed) 失败不可复现 每个测试对 Faker 设种子
共享可变测试数据(Shared mutable test data) 测试相互干扰 每个测试创建全新数据
到处手工造数据(Manual data creation everywhere) 重复、维护负担重 集中到工厂统一管理

八、关联参考

  • Fixtures/Hooks 模式:详见 fixtures-hooks.md,覆盖 fixture 生命周期、每测试数据库夹具与事务回滚;
  • 一次性数据库初始化:详见 global-setup.md,覆盖迁移与快照;
  • API Mocking 与套件结构:详见 test-suite-structure.md;
  • 仓库端到端测试总览:Sanity Studio 的完整 E2E 套件位于 e2e,其自定义 base test(e2e/studio-test.ts)与数据助手(e2e/helpers)是上述模式的最佳落地参考。

总结

一套健康的测试数据体系由四层构成:工厂层负责"造数据"(默认值、Trait、关联关系、Faker 随机),Fixture 层负责"注入与销毁"(生命周期绑定、teardown 清理),数据驱动层负责"批量场景"(数组、场景表、外部文件),播种层负责"真实后端数据"(API 播种、事务回滚)。配合"每测试独享数据、唯一 ID、自动清理"三大纪律,就能在 Sanity Studio 这类大型项目中做到测试数据可复用、可复现、可隔离——这正是从"能跑的测试"走向"可靠测试套件"的关键一步。

登录后查看全文
sanity