Playwright E2E 测试稳定化:无雪花策略,避免硬等待
在 CI/CD 流水线中,Playwright E2E 测试常常因高负载、网络延迟和资源竞争而出现雪花现象。本地环境强大的 CPU 和低延迟掩盖了竞态条件:前端已渲染 UI,但后端尚未将事务提交到数据库。在 CI 环境中,条件更严苛——测试捕获到不一致状态,随机失败。
可靠性的关键:从静态延迟转向动态轮询、幂等请求和稳健的依赖隔离。让我们深入探讨大规模减少雪花的技巧。
动态轮询取代固定超时
waitForTimeout(5000) 是反模式。在 90% 的情况下,雪花源于时机不匹配:后端在 5001ms 响应,而不是预期的 5000ms。解决方案?使用 expect.poll,持续检查状态直到成功或超时。
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 响应:
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。
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.poll和expect.toPass替换waitForTimeout,实现智能等待。 - 通过
X-Idempotency-Key实现幂等性,消除重试重复。 - 模拟演进:page.route → WireMock → Pact,实现真正隔离。
- TTL + cron/队列/分区,确保数据清洁。
- 稳定性是系统性的:测试的同时修复后端和基础设施。
— Editorial Team
暂无评论。