Playwright E2E 테스트 구축과 flaky 테스트 줄이기
안정적인 셀렉터·대기·격리로 흔들리는 테스트 줄이기

핵심 요약
- flaky의 주원인은 준비 전 조작·확인이며, 고정 시간 대기 대신 자동 대기와 웹 우선 단언을 쓴다.
- 셀렉터는 CSS 클래스 대신 역할·이름 기반으로 잡아 스타일 변경에 견디게 한다.
- 테스트마다 데이터를 격리하고, 인증은 세션 상태 재사용으로 우회한다.
- 재시도는 flaky를 숨기므로 재시도 통과분을 추적하고 원인을 제거해야 한다.
E2E 테스트는 실제 사용자 흐름을 잡아내는 대신, 조금만 관리가 소홀해도 "가끔 실패하는 테스트"로 변한다. flaky 테스트가 몇 개 생기면 팀은 실패를 무시하기 시작하고, 그러면 테스트가 있으나 마나 해진다. Playwright는 자동 대기와 웹 우선 단언을 기본으로 제공하지만, 그 위에 셀렉터와 격리 규칙을 얹지 않으면 흔들림은 여전히 남는다. 이 글은 처음 구축할 때부터 flaky를 억제하는 방법을 정리한다.
E2E 테스트가 잡는 결함과 비용
E2E는 여러 계층을 통과하는 결함을 잡는다. 로그인 후 결제까지처럼 단위 테스트로는 이어지지 않는 흐름, 라우팅과 상태 관리가 얽힌 문제, 실제 브라우저에서만 드러나는 렌더링 문제가 그렇다. 비용은 실행 시간과 유지보수다. 그래서 모든 것을 E2E로 덮지 않고, 핵심 사용자 경로에 집중하고 나머지는 단위·통합 테스트로 내리는 것이 원칙이다.
안정적인 셀렉터와 대기 전략
flaky의 가장 큰 원인은 "요소가 준비되기 전에 조작하거나 확인하는 것"이다. Playwright는 자동 대기를 제공하므로, 명시적인 sleep을 넣는 습관만 버려도 상당수가 사라진다. Playwright Best Practices 공식 문서에 따르면 셀렉터는 getByRole, getByLabel처럼 사용자가 인식하는 역할과 레이블을 기준으로 잡고 expect(locator).toBeVisible() 같은 웹 우선 단언(Web-First Assertions)을 사용해야 한다.
// 흔들리기 쉬운 방식
await page.locator(".btn-primary").click();
await page.waitForTimeout(2000);
// 권장: 역할·이름 기반 + 웹 우선 단언
await page.getByRole("button", { name: "결제하기" }).click();
await expect(page.getByRole("heading", { name: "결제 완료" })).toBeVisible();
CSS 클래스나 DOM 순서에 의존하는 셀렉터는 스타일 변경만으로 깨진다. 테스트 전용 속성을 붙여야 할 때는 data-testid를 쓰되, 남용하면 사용자 관점의 검증이 약해진다.
암묵적 대기와 명시적 대기 구분
자동 대기(암묵적)는 클릭 가능·표시 상태 같은 기본 조건을 처리한다. 서버 응답이나 특정 상태 전이처럼 자동 대기가 알 수 없는 조건은 명시적 단언으로 기다린다. "네트워크 요청이 끝났다"를 기다린다면 응답 대기나 상태 단언을 쓰고, 고정 시간 대기는 최후의 수단으로만 남긴다.
테스트 격리와 데이터 준비
테스트끼리 상태를 공유하면 실행 순서에 따라 결과가 달라진다. Playwright는 테스트마다 새 브라우저 컨텍스트를 만들므로 쿠키·스토리지가 기본적으로 격리된다. 문제는 데이터다. 다음 규칙을 지킨다.
- 각 테스트가 자기 데이터를 만들고 끝나면 정리한다. 가능하면 고유 식별자를 붙인다.
- 여러 테스트가 같은 계정을 수정하지 않도록 계정·리소스를 분리한다.
- 테스트 간 의존을 만들지 않는다. "앞 테스트가 만든 글"에 기대는 흐름은 순서 변경 시 무너진다.
데이터 준비는 UI를 통과하기보다 API나 시드 스크립트로 하는 편이 빠르고 안정적이다. 테스트마다 예측 가능한 고정 시드를 생성하고 개인정보가 섞이지 않게 관리하는 방법은 테스트 픽스처·시드 데이터 만들기에서 다룬 팩토리 패턴과 바로 연결된다. UI로 로그인과 데이터 생성을 반복하면 그 자체가 flaky 원인이 된다.
인증·세션 상태 재사용
로그인을 매 테스트마다 UI로 수행하면 시간도 오래 걸리고 실패 지점도 늘어난다. 인증은 한 번 수행한 뒤 저장된 세션 상태를 재사용한다.
// setup 프로젝트에서 로그인 후 상태 저장
await page.context().storageState({ path: "playwright/.auth/user.json" });
// playwright.config.ts
// use: { storageState: "playwright/.auth/user.json" }
세션 만료가 있는 서비스라면 setup 프로젝트를 테스트 실행 전에 갱신하도록 구성한다.
CI에서 재시도와 실패 분석
재시도는 flaky를 숨기는 도구이지 해결책이 아니다. 재시도로 통과한 테스트는 별도로 표시해 추적하고, 원인을 제거하는 작업을 남긴다. 실패 시에는 추적(trace)과 스크린샷을 남겨 로컬에서 재현할 수 있게 한다.
| 설정 | 용도 | 주의점 |
|---|---|---|
| retries | CI 환경 일시 오류 흡수 | 재시도 통과분을 추적해야 함 |
| trace | 실패 시점 재현 | on-first-retry 등으로 용량 관리 |
| workers | 병렬 실행 | 자원 경합으로 새 flaky 유발 가능 |
CI 파이프라인에 테스트를 붙이는 방식은 GitHub Actions 워크플로 정리 글과 함께 보면 아티팩트 보존과 실패 알림을 설계하기 쉽다.
로컬과 CI 환경 차이 줄이기
로컬에서는 통과하던 테스트가 CI에서만 실패하는 원인은 대개 환경 차이다. 해상도와 폰트, 시간대, 로케일, 네트워크 지연, 병렬 실행 수가 다르면 렌더링과 타이밍이 달라진다. CI 러너의 기본 해상도와 로케일을 고정하고, 테스트가 시스템 시간이나 위치에 의존하지 않게 한다. 병렬 실행에서 자원 경합이 flaky를 만든다면 워커 수를 제한하거나 무거운 테스트를 별도 프로젝트로 분리한다. 재현이 어려운 실패는 추적을 남겨 로컬에서 같은 조건으로 다시 돌려보는 절차를 마련한다.
유지보수 가능한 테스트 구조
테스트가 늘면 중복된 로그인·탐색 코드가 쌓인다. 페이지 객체나 헬퍼로 공통 동작을 묶되, 과도한 추상화는 오히려 디버깅을 어렵게 한다. 규칙은 "여러 테스트가 같은 순서를 반복할 때만 추출한다" 정도가 적당하다. 그리고 테스트 이름은 사용자 행동을 서술해, 실패 목록만 봐도 무엇이 깨졌는지 알 수 있게 한다.
마지막으로 flaky를 발견했을 때 "일단 재시도"로 넘기지 않는 문화가 중요하다. 재시도 횟수와 flaky 목록을 팀이 주기적으로 보고, 원인을 제거하는 시간을 확보해야 E2E 테스트가 신뢰를 유지한다.
초보자가 자주 실수하는 포인트
- waitForTimeout 같은 고정 대기로 타이밍 문제를 덮는다.
- UI로 매 테스트마다 로그인·데이터 생성해 그 자체가 flaky 원인이 된다.
- 테스트 간 데이터 의존을 만들어 실행 순서가 바뀌면 실패한다.
체크리스트
- 핵심 사용자 경로 위주로 E2E 범위를 정했다.
- 셀렉터를 역할·이름 기반으로 통일하고 고정 대기를 제거했다.
- 테스트별 데이터 격리와 정리 절차를 마련했다.
- 인증 세션 상태를 재사용하도록 setup을 구성했다.
- CI에서 재시도·trace를 설정하고 flaky 통과분을 추적한다.
자주 묻는 질문
모든 기능을 E2E로 덮어야 하나요?
아니다. E2E는 느리고 비용이 크므로 결제·가입처럼 여러 계층을 통과하는 핵심 경로에 집중하고, 세부 로직은 단위·통합 테스트로 내린다.
data-testid를 쓰면 안 되나요?
쓸 수 있지만 남용하면 사용자가 보는 레이블·역할 검증이 약해진다. 역할·이름으로 잡기 어려운 경우에 한정해 사용한다.
재시도를 몇 번으로 설정해야 하나요?
정해진 값은 없지만 재시도를 늘릴수록 flaky를 숨긴다. CI에서는 소수로 제한하고 재시도로 통과한 테스트를 별도로 추적해 원인을 제거한다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.