返回首页

稳定 Playwright 测试,避免雪花

本文分析 Playwright E2E 测试中雪花的原因,并提供解决方案:动态轮询 expect.poll、幂等性、多级 mocks 直至 Pact、测试数据清理策略。CI 中稳定性的实用代码示例。

对抗 Playwright 中的雪花:poll、mocks、contracts
Advertisement 728x90

Playwright E2E 测试稳定化:无雪花策略,避免硬等待

在 CI/CD 流水线中,Playwright E2E 测试常常因高负载、网络延迟和资源竞争而出现雪花现象。本地环境强大的 CPU 和低延迟掩盖了竞态条件:前端已渲染 UI,但后端尚未将事务提交到数据库。在 CI 环境中,条件更严苛——测试捕获到不一致状态,随机失败。

可靠性的关键:从静态延迟转向动态轮询、幂等请求和稳健的依赖隔离。让我们深入探讨大规模减少雪花的技巧。

动态轮询取代固定超时

waitForTimeout(5000) 是反模式。在 90% 的情况下,雪花源于时机不匹配:后端在 5001ms 响应,而不是预期的 5000ms。解决方案?使用 expect.poll,持续检查状态直到成功或超时。

Google AdInline article slot
await expect.poll(async () => {
  const order = await api.getOrder(id);
  return order.status;
}, {
  message: '等待已支付状态',
  timeout: 30000,
}).toBe('PAID');

Playwright 会自动调整轮询间隔。对于慢速场景,提升 test.setTimeout(60000),但不要手动调整间隔。

networkidle 陷阱与更好替代

waitUntil: 'networkidle' 等待 500ms 无网络活动。问题:分析工具、聊天小部件或心跳请求打破宁静,导致测试挂起至全局超时。

规则: 等待特定元素或 API 响应:

Google AdInline article slot
  • expect(page.getByText('就绪')).toBeVisible()
  • 对后端状态使用 expect.poll

对于重试操作块,使用 expect.toPass

await expect(async () => {
  await page.getByRole('button', { name: '刷新' }).click();
  await expect(page.getByText('状态:就绪')).toBeVisible();
}).toPass({
  timeout: 15000
});

幂等性抵御网络故障

在分布式系统中,网络故障重试会产生重复:首次 POST 成功,响应丢失,重试重复创建实体。结果?400 错误或不一致状态。

解决方案:从方法、URL 和负载生成 Idempotency-Key

Google AdInline article slot
import { createHash } from 'crypto';

export function generateIdempotencyKey(method: string, url: string, data: any): string {
  const payload = `${method}:${url}:${JSON.stringify(data)}`;
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

// 在 BaseApiClient 中
protected async post(url: string, data?: any) {
  const key = generateIdempotencyKey('POST', url, data);
  return await this.request.post(url, {
    data,
    headers: { 'X-Idempotency-Key': key }
  });
}

后端按键返回缓存结果。重试引发的雪花消失。

模拟:从基础到契约驱动的隔离级别

原生模拟使用 page.route

隔离 UI 与后端:

await page.route('**/api/orders', route => {
  route.fulfill({
    status: 500,
    body: JSON.stringify({ error: '内部服务器错误' })
  });
});

await page.goto('/orders');
await expect(page.getByText('操作失败')).toBeVisible();

局限:遗漏通过 request fixture 的服务器端请求。

基础设施模拟 (WireMock)

后端依赖外部服务(支付、短信)?启动 WireMock:

# docker-compose.yml
services:
  wiremock:
    image: wiremock/wiremock:3.3.1
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings

映射示例(payment.json):

{
  "request": {
    "method": "POST",
    "url": "/v1/payments"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay_test_123",
      "status": "succeeded"
    }
  }
}

测试独立于外部服务可用性。

契约测试 vs. 欺骗性模拟

模拟会撒谎:前端期待 order_id,后端发送 orderId——测试绿灯通过,生产崩溃。

消费者驱动契约 (Pact) 通过 JSON 契约锁定期望:

import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-tests',
  provider: 'order-service',
  dir: './pacts',
});

it('returns order by id', async () => {
  await provider
    .given('order ord_123 exists')
    .uponReceiving('GET /orders/ord_123')
    .withRequest({ method: 'GET', path: '/orders/ord_123' })
    .willRespondWith({
      status: 200,
      body: {
        order_id: MatchersV3.string('ord_123'),
        status: MatchersV3.string('PAID'),
      },
    })
    .executeTest(async (mockServer) => {
      const order = await fetchOrder(mockServer.url, 'ord_123');
      expect(order.status).toBe('PAID');
    });
});

后端在其 CI 中验证契约。API 变更在部署前被捕获。

测试数据卫生

测试 clutter 数据库——性能下降,唯一约束引发雪花。

1. TTL 与后台清理

await api.createUser({
  email: `test_${Date.now()}@example.com`, 
  expires_at: new Date(Date.now() + 24*60*60*1000).toISOString()
});

PostgreSQL pg_cron

SELECT cron.schedule('cleanup-test-data', '0 * * * *', $$
  DELETE FROM users WHERE expires_at < NOW() AND is_test = true;
$$);

2. 清理队列

创建时收集 ID 到队列,全局 teardown 时清理:

protected async post(url: string, data?: any) {
  const response = await this.request.post(url, { data });
  const body = await response.json();
  
  if (body.id) {
    cleanupQueue.push({ url, id: body.id });
  }
  return response;
}

3. 表分区

CREATE TABLE orders_test (
  id UUID, created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_test_2024_06 
  PARTITION OF orders_test
  FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

丢弃分区是 O(1)。

关键要点:

  • expect.pollexpect.toPass 替换 waitForTimeout,实现智能等待。
  • 通过 X-Idempotency-Key 实现幂等性,消除重试重复。
  • 模拟演进:page.route → WireMock → Pact,实现真正隔离。
  • TTL + cron/队列/分区,确保数据清洁。
  • 稳定性是系统性的:测试的同时修复后端和基础设施。

— Editorial Team

Advertisement 728x90

继续阅读