编程 如何架构 Playwright 测试以验证跨 UI 和 API 状态的原子事务回滚

2026-09-06 05:13:50

如何架构 Playwright 测试以验证跨 UI 和 API 状态的原子事务回滚

一位开发者在 Dev.to 上发表文章,探讨了一个具有挑战性的测试架构问题:如何使用 Playwright 构建端到端(E2E)测试,验证跨 UI 和 API 状态的原子事务回滚。这是一个常见但复杂的测试场景,涉及前端 UI、后端 API、数据库事务的协同验证。

背景:原子事务与回滚

什么是原子事务

原子事务(Atomic Transaction)是数据库事务的 ACID 特性之一:

  • 原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败回滚,不存在部分成功的中间状态
  • 一致性(Consistency):事务将数据库从一个一致状态转移到另一个一致状态
  • 隔离性(Isolation):并发事务之间互不干扰
  • 持久性(Durability):事务一旦提交,其结果永久保存

为什么需要测试回滚

在实际应用中,事务回滚是一个关键功能:

  • 数据一致性:确保操作失败时,数据不会处于不一致的中间状态
  • 错误恢复:确保系统在遇到错误时能够正确恢复
  • 用户体验:确保用户在操作失败后,系统状态符合预期
  • 业务逻辑:很多业务逻辑依赖事务回滚(如支付、订单、库存)

测试的挑战

测试原子事务回滚面临多个挑战:

  1. 多系统协同:需要同时验证 UI 状态、API 响应、数据库状态
  2. 异步操作:很多操作是异步的,需要处理时序问题
  3. 错误注入:需要能够在特定点注入错误,触发回滚
  4. 状态验证:需要验证回滚后各个系统的状态是否正确
  5. 测试隔离:确保测试之间互不影响,每个测试从干净的状态开始

测试架构设计

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' };
  }
}

具体测试场景

场景一:订单创建失败时的回滚

这是一个典型的事务回滚场景:

  1. 用户在 UI 上提交订单
  2. 后端开始事务:创建订单记录、扣减库存、处理支付
  3. 支付处理失败
  4. 整个事务回滚:订单记录被删除、库存恢复
  5. 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); // 没有新订单
});

场景二:多步骤操作中的部分失败

更复杂的场景:一个操作涉及多个步骤,某个步骤失败时所有步骤都回滚:

  1. 用户注册账号
  2. 系统创建用户记录、创建默认配置、发送欢迎邮件、初始化用户数据
  3. 初始化用户数据失败(如磁盘空间不足)
  4. 所有步骤回滚:用户记录删除、配置删除

测试架构

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);
});

场景三:并发操作中的事务隔离

测试并发操作时的事务隔离性:

  1. 用户 A 和用户 B 同时购买最后一件商品
  2. 两个事务并发执行
  3. 只有一个能成功,另一个回滚
  4. 库存不会变成负数

测试架构

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 的显式等待(expectwaitFor),而不是固定的 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 状态的原子事务回滚,是一个复杂但重要的测试场景。

核心要点:

  1. 挑战:多系统协同、异步操作、错误注入、状态验证、测试隔离
  2. 测试架构:分层测试(E2E + API + 数据库)、测试环境准备、错误注入机制
  3. 测试场景:订单创建失败回滚、多步骤操作部分失败、并发操作事务隔离
  4. 关键技术:测试数据管理、等待和时序处理、API 和数据库验证、错误注入和恢复
  5. 最佳实践:单一职责、独立运行、可重复、快速反馈、页面对象模式、测试金字塔
  6. 常见陷阱:只验证 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

推荐文章

程序员茄子在线接单