같은 코드인데 CI에서만 실패하는 이유

브라우저 E2E 테스트가 로컬에서는 통과하지만 CI에서 간헐적으로 실패하면, 단순히 대기 시간을 늘리는 방식부터 피해야 합니다. CI 실행 환경은 CPU와 네트워크가 느리고, 브라우저 시작 순서와 데이터 상태도 매번 달라집니다. 고정된 시간만 기다리는 테스트는 환경 변화에 취약하며, 실패 원인을 숨긴 채 전체 배포 시간을 늘립니다.

flaky test를 줄이는 핵심은 사용자가 실제로 확인할 수 있는 상태를 기준으로 기다리고, 테스트 데이터·네트워크·병렬 실행을 분리하며, 실패 시 재현 가능한 증거를 남기는 것입니다. 아래 순서대로 적용하면 일시적 재시도에 의존하는 비율을 낮출 수 있습니다.

1. 시간 대기 대신 화면 상태를 기다리기

waitForTimeout은 애니메이션이나 API 응답이 느린 날에는 부족하고, 빠른 날에는 낭비입니다. 버튼이 눌릴 수 있는지, 결과 목록이 표시되었는지, 요청이 끝났는지를 조건으로 검증해야 합니다. 특히 CSS 클래스나 DOM 구조에 강하게 묶인 선택자보다 역할과 접근성 이름을 우선하면 화면 리팩터링에도 견고합니다.

import { test, expect } from '@playwright/test'; test('주문을 저장하면 완료 메시지를 표시한다', async ({ page }) => { await page.goto('/orders/new'); await page.getByLabel('상품명').fill('테스트 상품'); await page.getByRole('button', { name: '저장' }).click(); await expect(page.getByRole('status')).toHaveText('주문이 저장되었습니다.'); await expect(page).toHaveURL(/\/orders\/\d+$/); });

상태 메시지가 없다면 제품 화면에도 개선 여지가 있습니다. 저장 성공·실패를 명확한 role=status 또는 오류 메시지로 제공하면 사용자 경험과 자동화 안정성을 함께 높일 수 있습니다.

2. 테스트마다 독립된 데이터를 만들기

공용 테스트 계정, 고정 주문번호, 이전 실행이 남긴 레코드는 병렬 실행에서 충돌을 만듭니다. 각 테스트가 실행 시작 시 필요한 데이터를 API 또는 데이터베이스 시드로 만들고, 종료 시 정리하도록 구성합니다. 이름과 이메일에는 실행별 식별자를 붙여 어느 실패가 어떤 데이터와 연결되는지도 알 수 있게 합니다.

test.beforeEach(async ({ request }, testInfo) => { const email = 'e2e-' + testInfo.workerIndex + '-' + Date.now() + '@example.test'; const response = await request.post('/api/test/users', { data: { email, role: 'customer' } }); expect(response.ok()).toBeTruthy(); });

운영 API와 테스트 데이터 생성 API는 분리하고, 테스트 전용 엔드포인트는 사설 네트워크·별도 인증·운영 비활성화로 보호해야 합니다. 운영 데이터 정리를 E2E 테스트가 직접 수행하게 두면 오작동 범위가 커집니다.

3. 네트워크와 외부 의존성을 통제하기

결제, 문자, 지도, 추천 API처럼 제어할 수 없는 외부 서비스는 E2E에서 실제 호출하지 않는 편이 좋습니다. 응답 형식이 필요한 화면은 page.route로 고정된 응답을 제공하고, 실제 연동은 별도의 계약 테스트 또는 스테이징 통합 테스트로 확인합니다. 이렇게 분리하면 외부 장애가 프런트엔드 회귀 테스트를 가리는 일을 줄일 수 있습니다.

await page.route('**/api/recommendations', async route => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'p-1', name: '추천 상품' }] }) }); });

인증 토큰 만료도 자주 놓치는 원인입니다. 로그인 UI를 모든 테스트에서 반복하기보다, 신뢰할 수 있는 설정 단계에서 저장한 인증 상태를 재사용하되 권한별 상태 파일을 분리합니다. 단, 상태 파일에는 실제 운영 계정의 쿠키나 비밀값을 넣지 않아야 합니다.

4. 실패한 실행에서 증거를 남기기

재시도만 켜면 첫 실패의 원인이 사라집니다. CI는 trace, screenshot, video를 남기고 결과물을 보관해야 합니다. trace에는 클릭 대상, 네트워크 요청, 콘솔 오류, 화면 변화가 함께 기록되어 로컬 재현 전에 원인을 좁힐 수 있습니다.

import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined });

재시도는 임시 완충 장치일 뿐 품질 지표가 아닙니다. 재시도로 통과한 테스트 수를 CI 리포트에서 따로 집계하고, 반복되는 항목은 담당자가 원인을 수정할 때까지 안정성 부채로 관리하는 것이 좋습니다.

배포 전 체크리스트

  • 고정 시간 대기 대신 사용자에게 보이는 상태를 assertion으로 확인한다.
  • 테스트 데이터와 계정을 실행 단위로 분리하고 추적 가능한 식별자를 사용한다.
  • 외부 서비스 응답은 E2E에서 통제하고 실제 연동 검증은 별도 단계로 둔다.
  • 실패 시 trace, 화면 캡처, 비디오를 보관해 재현 시간을 줄인다.
  • 재시도 성공 건도 실패로 기록해 근본 원인을 제거한다.

이 다섯 가지를 적용하면 CI 실패를 단순한 운 문제로 넘기지 않고, 배포 판단에 신뢰할 수 있는 테스트 신호로 바꿀 수 있습니다.