Playwright E2E 테스트 안정화: 플레이크 없는 전략, 하드 대기 없이
CI/CD 파이프라인에서 Playwright E2E 테스트는 높은 부하, 네트워크 지연, 자원 경쟁으로 인해 자주 플레이크가 발생합니다. 로컬 환경에서는 강력한 CPU와 낮은 지연이 레이스 컨디션을 숨겨줍니다: 프론트엔드가 UI를 렌더링하지만 백엔드가 아직 데이터베이스에 트랜잭션을 커밋하지 않은 상태죠. CI 환경에서는 조건이 더 가혹해 불안정한 상태를 포착해 테스트가 무작위로 실패합니다.
신뢰성의 핵심: 정적 지연에서 동적 폴링, 멱등 요청, 강력한 의존성 격리로 전환하세요. 대규모 플레이크를 줄이는 기술을 살펴보겠습니다.
고정 타임아웃 대신 동적 폴링
waitForTimeout(5000)은 안티패턴입니다. 90%의 플레이크는 타이밍 불일치에서 비롯됩니다: 백엔드가 예상 5000ms 대신 5001ms에 응답하는 거죠. 해결책? 성공하거나 타임아웃될 때까지 상태를 확인하는 expect.poll입니다.
await expect.poll(async () => {
const order = await api.getOrder(id);
return order.status;
}, {
message: 'PAID 상태 대기 중',
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 픽스처를 통한 서버사이드 요청을 놓칩니다.
인프라 모킹 (WireMock)
백엔드가 외부 서비스(결제, SMS)에 의존하나요? 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를 보내면 테스트는 통과하지만 프로덕션에서 크래시하죠.
Consumer-Driven Contracts (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 변경이 배포 전에 잡힙니다.
테스트 데이터 위생 관리
테스트가 DB를 어지럽혀 성능이 떨어지고 유니크 제약으로 플레이크가 발생합니다.
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)입니다.
핵심 요약:
waitForTimeout을expect.poll과expect.toPass로 교체해 스마트 대기.X-Idempotency-Key로 멱등성 확보해 재시도 중복 제거.- 모킹 진화: page.route → WireMock → Pact로 진짜 격리.
- TTL + cron/큐/파티션으로 깨끗한 데이터.
- 안정성은 시스템적: 테스트와 함께 백엔드, 인프라 수정.
— Editorial Team
아직 댓글이 없습니다.