如何架构 Playwright 测试以验证跨 UI 和 API 状态的原子事务回滚
一位开发者在 Dev.to 上发表文章,探讨了一个具有挑战性的测试架构问题:如何使用 Playwright 构建端到端(E2E)测试,验证跨 UI 和 API 状态的原子事务回滚。这是一个常见但复杂的测试场景,涉及前端 UI、后端 API、数据库事务的协同验证。
背景:原子事务与回滚
什么是原子事务
原子事务(Atomic Transaction)是数据库事务的 ACID 特性之一:
- 原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败回滚,不存在部分成功的中间状态
- 一致性(Consistency):事务将数据库从一个一致状态转移到另一个一致状态
- 隔离性(Isolation):并发事务之间互不干扰
- 持久性(Durability):事务一旦提交,其结果永久保存
为什么需要测试回滚
在实际应用中,事务回滚是一个关键功能:
- 数据一致性:确保操作失败时,数据不会处于不一致的中间状态
- 错误恢复:确保系统在遇到错误时能够正确恢复
- 用户体验:确保用户在操作失败后,系统状态符合预期
- 业务逻辑:很多业务逻辑依赖事务回滚(如支付、订单、库存)
测试的挑战
测试原子事务回滚面临多个挑战:
- 多系统协同:需要同时验证 UI 状态、API 响应、数据库状态
- 异步操作:很多操作是异步的,需要处理时序问题
- 错误注入:需要能够在特定点注入错误,触发回滚
- 状态验证:需要验证回滚后各个系统的状态是否正确
- 测试隔离:确保测试之间互不影响,每个测试从干净的状态开始
测试架构设计
1. 测试分层
文章建议采用分层的测试架构:
┌─────────────────────────────────────────┐
│ E2E 测试(Playwright) │
│ 模拟用户操作,验证 UI 和 API 状态 │
├─────────────────────────────────────────┤
│ API 测试层 │
│ 直接调用 API,验证业务逻辑和事务 │
├─────────────────────────────────────────┤
│ 数据库测试层 │
│ 直接查询数据库,验证数据状态 │
└─────────────────────────────────────────┘
- E2E 测试:使用 Playwright 模拟真实用户操作,验证端到端的用户体验
- API 测试:直接调用后端 API,验证业务逻辑和事务处理
- 数据库测试:直接查询数据库,验证数据的最终状态
2. 测试环境准备
每个测试需要从干净的状态开始:
- 数据库重置:每个测试前重置数据库到已知状态
- 测试数据:使用固定的测试数据,确保可重复性
- Mock 外部服务:Mock 外部依赖(如支付网关、邮件服务),以便控制错误注入
- 环境隔离:每个测试使用独立的环境,避免互相影响
// Playwright 测试的全局设置
import { test as base } from '@playwright/test';
// 扩展 test fixture,添加数据库和 API 客户端
export const test = base.extend<{
db: DatabaseClient;
api: ApiClient;
testData: TestData;
}>({
// 数据库客户端
db: async ({}, use) => {
const db = new DatabaseClient(process.env.TEST_DB_URL!);
await use(db);
await db.close();
},
// API 客户端
api: async ({}, use) => {
const api = new ApiClient(process.env.TEST_API_URL!);
await use(api);
},
// 测试数据,每个测试前重置
testData: async ({ db }, use) => {
const data = await db.resetAndSeed();
await use(data);
await db.cleanup(data);
},
});
3. 错误注入机制
为了测试回滚,需要能够在特定点注入错误:
- Mock 外部服务:Mock 支付网关、第三方 API 等,使其在特定条件下返回错误
- 测试专用端点:后端提供测试专用的错误注入端点
- 数据库触发器:使用数据库触发器在特定操作时抛出异常
- 网络延迟/中断:模拟网络延迟或中断,触发超时回滚
// Mock 支付网关,支持错误注入
class MockPaymentGateway {
private shouldFail = false;
// 设置下一次支付是否失败
setNextPaymentFail(fail: boolean) {
this.shouldFail = fail;
}
// 处理支付
async processPayment(amount: number): Promise<PaymentResult> {
if (this.shouldFail) {
this.shouldFail = false; // 重置
throw new Error('Payment failed: insufficient funds');
}
return { success: true, transactionId: 'mock-tx-123' };
}
}
具体测试场景
场景一:订单创建失败时的回滚
这是一个典型的事务回滚场景:
- 用户在 UI 上提交订单
- 后端开始事务:创建订单记录、扣减库存、处理支付
- 支付处理失败
- 整个事务回滚:订单记录被删除、库存恢复
- UI 显示错误消息,用户可以重新尝试
测试步骤
test('订单支付失败时事务回滚', async ({ page, db, api, testData }) => {
// 1. 准备:设置支付失败
const mockPayment = new MockPaymentGateway();
mockPayment.setNextPaymentFail(true);
// 2. 记录初始状态
const initialStock = await db.getProductStock(testData.productId);
const initialOrderCount = await db.getOrderCount(testData.userId);
// 3. UI 操作:用户提交订单
await page.goto(`/products/${testData.productId}`);
await page.click('#add-to-cart');
await page.goto('/checkout');
await page.fill('#shipping-address', testData.address);
await page.click('#place-order');
// 4. 验证 UI 状态:显示错误消息
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toHaveText(/支付失败/);
// 5. 验证 API 状态:查询订单不存在
const orders = await api.getUserOrders(testData.userId);
expect(orders).toHaveLength(0);
// 6. 验证数据库状态:库存恢复,没有订单记录
const finalStock = await db.getProductStock(testData.productId);
const finalOrderCount = await db.getOrderCount(testData.userId);
expect(finalStock).toBe(initialStock); // 库存恢复
expect(finalOrderCount).toBe(initialOrderCount); // 没有新订单
});
场景二:多步骤操作中的部分失败
更复杂的场景:一个操作涉及多个步骤,某个步骤失败时所有步骤都回滚:
- 用户注册账号
- 系统创建用户记录、创建默认配置、发送欢迎邮件、初始化用户数据
- 初始化用户数据失败(如磁盘空间不足)
- 所有步骤回滚:用户记录删除、配置删除
测试架构
test('多步骤注册失败时完全回滚', async ({ page, db, api }) => {
// 1. 注入错误:用户数据初始化失败
await api.injectError('user-data-init', 'DISK_FULL');
// 2. 记录初始状态
const initialUserCount = await db.getUserCount();
const initialConfigCount = await db.getConfigCount();
// 3. UI 操作:用户注册
await page.goto('/register');
await page.fill('#email', 'test@example.com');
await page.fill('#password', 'SecurePass123!');
await page.click('#register');
// 4. 验证 UI:显示注册失败
await expect(page.locator('.error')).toHaveText(/注册失败/);
// 5. 验证 API:用户不存在
const user = await api.getUserByEmail('test@example.com');
expect(user).toBeNull();
// 6. 验证数据库:完全回滚
expect(await db.getUserCount()).toBe(initialUserCount);
expect(await db.getConfigCount()).toBe(initialConfigCount);
expect(await db.getUserDataCount('test@example.com')).toBe(0);
});
场景三:并发操作中的事务隔离
测试并发操作时的事务隔离性:
- 用户 A 和用户 B 同时购买最后一件商品
- 两个事务并发执行
- 只有一个能成功,另一个回滚
- 库存不会变成负数
测试架构
test('并发购买最后一件商品时事务隔离', async ({ browser, db, api, testData }) => {
// 1. 设置库存为 1
await db.setProductStock(testData.productId, 1);
// 2. 两个用户同时购买(使用两个浏览器上下文)
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
// 3. 同时执行购买操作
const [resultA, resultB] = await Promise.allSettled([
purchaseProduct(pageA, testData.productId, 'userA'),
purchaseProduct(pageB, testData.productId, 'userB'),
]);
// 4. 验证:恰好一个成功,一个失败
const successCount = [resultA, resultB].filter(r => r.status === 'fulfilled').length;
expect(successCount).toBe(1);
// 5. 验证数据库:库存为 0,不是 -1
const finalStock = await db.getProductStock(testData.productId);
expect(finalStock).toBe(0);
// 6. 验证:只有一个订单
const orders = await db.getProductOrders(testData.productId);
expect(orders).toHaveLength(1);
});
关键技术要点
1. 测试数据管理
- 固定种子数据:使用固定的种子数据,确保测试可重复
- 每个测试独立:每个测试使用独立的测试数据,避免互相影响
- 清理机制:测试后自动清理测试数据
- 数据工厂:使用数据工厂模式创建测试数据,提高可维护性
// 数据工厂示例
class TestDataFactory {
constructor(private db: DatabaseClient) {}
async createUser(overrides: Partial<User> = {}): Promise<User> {
const user: User = {
id: generateId(),
email: `test-${Date.now()}@example.com`,
name: 'Test User',
...overrides,
};
await this.db.insertUser(user);
return user;
}
async createProduct(overrides: Partial<Product> = {}): Promise<Product> {
const product: Product = {
id: generateId(),
name: 'Test Product',
price: 99.99,
stock: 100,
...overrides,
};
await this.db.insertProduct(product);
return product;
}
}
2. 等待和时序处理
E2E 测试中经常需要处理异步操作和时序问题:
- 显式等待:使用 Playwright 的显式等待(
expect、waitFor),而不是固定的sleep - 网络等待:等待网络请求完成,而不是等待固定时间
- UI 状态等待:等待 UI 元素达到预期状态
- 轮询验证:对于最终一致性的系统,使用轮询验证状态
// 好的做法:使用显式等待
await expect(page.locator('.success-message')).toBeVisible({ timeout: 10000 });
// 不好的做法:使用固定等待
await page.waitForTimeout(3000);
// 等待网络请求
const responsePromise = page.waitForResponse('**/api/orders');
await page.click('#place-order');
const response = await responsePromise;
expect(response.status()).toBe(200);
// 轮询验证最终一致性
await expect.poll(async () => {
return await db.getOrderStatus(orderId);
}, { timeout: 10000 }).toBe('completed');
3. API 和数据库验证
E2E 测试不应只验证 UI,还应验证 API 和数据库状态:
- API 验证:直接调用 API 验证业务逻辑
- 数据库验证:直接查询数据库验证数据状态
- 多维度验证:同时验证 UI、API、数据库三个维度
- 状态一致性:验证三个维度的状态是否一致
// 多维度验证辅助函数
async function verifyOrderState(
page: Page,
api: ApiClient,
db: DatabaseClient,
orderId: string,
expectedStatus: string
) {
// UI 验证
await expect(page.locator(`#order-${orderId} .status`)).toHaveText(expectedStatus);
// API 验证
const apiOrder = await api.getOrder(orderId);
expect(apiOrder.status).toBe(expectedStatus);
// 数据库验证
const dbOrder = await db.getOrder(orderId);
expect(dbOrder.status).toBe(expectedStatus);
// 一致性验证
expect(apiOrder).toEqual(dbOrder);
}
4. 错误注入和恢复
测试回滚需要能够注入错误,并在测试后恢复:
- 可配置的错误注入:通过 API 或配置控制错误注入
- 自动恢复:测试后自动恢复正常状态
- 错误类型覆盖:覆盖各种错误类型(网络错误、业务错误、系统错误)
- 错误点选择:能够在事务的不同阶段注入错误
// 错误注入管理器
class ErrorInjectionManager {
constructor(private api: ApiClient) {}
// 在特定操作点注入错误
async injectError(operation: string, errorType: string) {
await api.post('/test/inject-error', { operation, errorType });
}
// 清除所有错误注入
async clearAll() {
await api.post('/test/clear-errors');
}
// 使用装饰器模式:测试前注入,测试后清除
async withError<T>(operation: string, errorType: string, fn: () => Promise<T>): Promise<T> {
await this.injectError(operation, errorType);
try {
return await fn();
} finally {
await this.clearAll();
}
}
}
最佳实践
1. 测试设计原则
- 单一职责:每个测试只验证一个场景
- 独立运行:每个测试可以独立运行,不依赖其他测试
- 可重复:测试结果可重复,不受环境和时序影响
- 快速反馈:测试应该快速运行,提供快速反馈
- 清晰命名:测试名称清晰描述测试场景和预期结果
2. 测试组织
- 按功能模块组织:按业务功能模块组织测试文件
- 按场景分类:按正常流程、异常流程、边界条件分类
- 共享 fixture:使用 Playwright 的 fixture 机制共享测试设置
- 页面对象模式:使用页面对象模式(Page Object Model)封装 UI 操作
// 页面对象模式示例
class CheckoutPage {
constructor(private page: Page) {}
async navigate() {
await this.page.goto('/checkout');
}
async fillShippingAddress(address: string) {
await this.page.fill('#shipping-address', address);
}
async selectPaymentMethod(method: string) {
await this.page.selectOption('#payment-method', method);
}
async placeOrder() {
await this.page.click('#place-order');
}
async getErrorMessage(): Promise<string> {
return await this.page.locator('.error-message').textContent() || '';
}
async waitForSuccess() {
await expect(this.page.locator('.success-message')).toBeVisible();
}
async waitForError() {
await expect(this.page.locator('.error-message')).toBeVisible();
}
}
3. 性能和稳定性
- 避免过度 E2E:E2E 测试慢且不稳定,不要过度使用。简单的逻辑用单元测试,复杂的集成用 API 测试,只有真正的端到端场景用 E2E
- 测试金字塔:遵循测试金字塔:大量单元测试、适量集成测试、少量 E2E 测试
- 重试机制:对于不稳定的测试,使用合理的重试机制
- 并行执行:使用 Playwright 的并行执行能力,缩短测试时间
4. 维护性
- 抽象复用:抽象通用的测试逻辑,提高复用性
- 配置外部化:将测试配置(URL、凭证、超时)外部化
- 文档注释:为复杂的测试场景添加文档和注释
- 定期清理:定期清理过时的测试,更新失效的测试
常见陷阱和解决方案
陷阱一:测试只验证 UI,不验证后端
问题:只验证 UI 显示成功,但后端数据可能不正确。
解决方案:同时验证 UI、API、数据库三个维度,确保状态一致。
陷阱二:使用固定等待导致不稳定
问题:使用 sleep(3000) 等待操作完成,导致测试不稳定且慢。
解决方案:使用显式等待,等待特定条件满足。
陷阱三:测试之间数据污染
问题:一个测试创建的数据影响另一个测试。
解决方案:每个测试前重置数据库,使用独立的测试数据。
陷阱四:错误注入不可控
问题:错误注入影响其他测试,或无法在特定点注入。
解决方案:使用可配置的错误注入机制,测试后自动清除。
陷阱五:并发测试资源竞争
问题:并行测试时,多个测试竞争同一资源(如同一产品的库存)。
解决方案:每个测试使用独立的测试数据,或使用资源池管理。
总结
架构 Playwright 测试以验证跨 UI 和 API 状态的原子事务回滚,是一个复杂但重要的测试场景。
核心要点:
- 挑战:多系统协同、异步操作、错误注入、状态验证、测试隔离
- 测试架构:分层测试(E2E + API + 数据库)、测试环境准备、错误注入机制
- 测试场景:订单创建失败回滚、多步骤操作部分失败、并发操作事务隔离
- 关键技术:测试数据管理、等待和时序处理、API 和数据库验证、错误注入和恢复
- 最佳实践:单一职责、独立运行、可重复、快速反馈、页面对象模式、测试金字塔
- 常见陷阱:只验证 UI、固定等待、数据污染、错误注入不可控、并发资源竞争
对于需要测试事务回滚的开发者来说,这篇文章提供了一个实用的架构参考。关键是不要只验证 UI 层面的表现,而要深入验证 API 和数据库层面的状态,确保事务回滚真正生效。
随着应用越来越复杂,事务回滚的测试也越来越重要。一个好的测试架构不仅能确保数据一致性,还能提高开发效率和系统可靠性。
原文链接:https://dev.to/styrow_dev/how-do-you-architect-a-playwright-test-to-verify-atomic-transaction-rollback-across-ui-and-api-24h