Sanity Studio 端到端测试数据管理:Playwright 测试数据工厂与生成器完整实战指南
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 版本; - 数据中心化:
sanityClientfixture 统一注入项目 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重写pagefixture,用addInitScript注入剪贴板 mock,并对外暴露setClipboardItems、getClipboardItemsAsText等操作型 fixture——测试只需声明参数,即可获得稳定的剪贴板控制能力; - e2e/tests/inputs/array-capabilities.spec.ts 展示了"每测试独享数据 + 显式清理":在
testDocfixture 内用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 这类大型项目中做到测试数据可复用、可复现、可隔离——这正是从"能跑的测试"走向"可靠测试套件"的关键一步。