홈으로 돌아가기

Playwright 테스트를 플레이크 없이 안정화하기

이 글은 Playwright E2E 테스트의 플레이크 원인을 분석하고 해결책을 제시합니다: 동적 폴링 expect.poll, idempotency, Pact까지의 다단계 mocks, 테스트 데이터 정리 전략. CI 안정성을 위한 실전 코드 예제.

Playwright에서 플레이크와 싸우기: poll, mocks, contracts
Advertisement 728x90

Playwright E2E 테스트 안정화: 플레이크 없는 전략, 하드 대기 없이

CI/CD 파이프라인에서 Playwright E2E 테스트는 높은 부하, 네트워크 지연, 자원 경쟁으로 인해 자주 플레이크가 발생합니다. 로컬 환경에서는 강력한 CPU와 낮은 지연이 레이스 컨디션을 숨겨줍니다: 프론트엔드가 UI를 렌더링하지만 백엔드가 아직 데이터베이스에 트랜잭션을 커밋하지 않은 상태죠. CI 환경에서는 조건이 더 가혹해 불안정한 상태를 포착해 테스트가 무작위로 실패합니다.

신뢰성의 핵심: 정적 지연에서 동적 폴링, 멱등 요청, 강력한 의존성 격리로 전환하세요. 대규모 플레이크를 줄이는 기술을 살펴보겠습니다.

고정 타임아웃 대신 동적 폴링

waitForTimeout(5000)은 안티패턴입니다. 90%의 플레이크는 타이밍 불일치에서 비롯됩니다: 백엔드가 예상 5000ms 대신 5001ms에 응답하는 거죠. 해결책? 성공하거나 타임아웃될 때까지 상태를 확인하는 expect.poll입니다.

Google AdInline article slot
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 응답을 기다리세요:

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 픽스처를 통한 서버사이드 요청을 놓칩니다.

인프라 모킹 (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)입니다.

핵심 요약:

  • waitForTimeoutexpect.pollexpect.toPass로 교체해 스마트 대기.
  • X-Idempotency-Key로 멱등성 확보해 재시도 중복 제거.
  • 모킹 진화: page.route → WireMock → Pact로 진짜 격리.
  • TTL + cron/큐/파티션으로 깨끗한 데이터.
  • 안정성은 시스템적: 테스트와 함께 백엔드, 인프라 수정.

— Editorial Team

Advertisement 728x90

다음 읽기