Playwright E2E 테스트의 flaky를 줄이면서 배운 것
flaky 테스트좀 없애보자
들어가며
오늘은 Playwright로 E2E 테스트를 작성하면서 만난 flaky test 문제를 정리해보려고 한다.
작성한 테스트는 B2B 출고 플로우였다. 출고 주문을 만들고, 피킹 작업 생성과 작업자 할당을 거쳐 PDA 피킹과 패킹까지 이어지는 시나리오다. 로컬에서는 통과했지만 CI에서는 실패했고, 다시 실행하면 통과하는 경우도 있었다.
이 글에서는 그 과정에서 어떤 문제가 있었는지, 왜 flaky하게 동작했는지, 그리고 테스트를 조금 더 믿을 수 있게 만들기 위해 어떤 기준을 세웠는지 정리한다.
Playwright를 쓴다고 flaky가 사라지지는 않았다
Playwright는 좋은 도구다. auto-wait도 있고, trace도 좋고, locator API도 Selenium 시절보다 훨씬 낫다.
하지만 도구를 바꿨다고 테스트가 자동으로 안정화되지는 않았다. 우리가 겪은 문제는 브라우저 제어 자체보다 테스트 로직과 데이터 의존성에 가까웠다.
- 버튼은 보이지만 실제로는 overlay가 클릭을 막고 있었다.
- 성공 toast를 기다렸지만 CI에서는 타이밍을 놓쳤다.
waitForResponse가 이미 지나간 응답을 기다리고 있었다.- 방금 만든 주문이 아니라 목록의 첫 번째 row를 클릭하고 있었다.
- 테스트 편의를 위해 제품 코드에 우회로를 넣으려다 실제 화면을 건드릴 뻔했다.
이 문제들은 Playwright가 대신 해결해주지 않는다. 테스트가 무엇을 기다리고, 무엇을 검증하고, 어떤 데이터를 따라갈지 직접 정해야 한다.
Case 1. 버튼은 보이는데 클릭이 안 되는 문제
가장 먼저 만난 실패는 로그인부터였다.

로그를 보면 Playwright는 LOGIN 버튼을 찾고 있었고, 로그인 버튼도 보였다. 여기서 발생한 문제는 클릭이 되질 않았다. 버튼 위에 남아 있던 div가 pointer event를 가로채고 있었다.
TimeoutError: locator.click: Timeout 10000ms exceeded
waiting for getByRole('button', { name: 'LOGIN' })
<div class="css-k54rj5">...</div> intercepts pointer events로컬에서는 이 overlay가 금방 사라졌지만 하지만 CI에서는 렌더링이 조금 느렸고, 그 짧은 차이가 클릭 실패로 이어졌다.
당시 코드는 이렇게 생각하고 있었다.
await expect(page.getByRole("button", { name: "LOGIN" })).toBeVisible();
await page.getByRole("button", { name: "LOGIN" }).click();“버튼이 보이면 누르면 되겠지”라고 생각한 것이다. 하지만 E2E에서는 visible과 clickable이 다르다. 사용자가 실제로 누를 수 있으려면 버튼이 보이는 것뿐 아니라, 클릭을 막는 요소가 없어야 한다.
그래서 이 케이스 이후에는 로그인처럼 overlay나 loading이 섞이는 화면에서 버튼 자체만 기다리지 않도록 했다. 화면이 조작 가능한 상태가 됐는지 함께 보게 됐다.
핵심은 timeout을 늘리는 게 아니었다.
await page.waitForTimeout(3000);이런 코드는 운이 좋으면 통과율을 올려주지만, 왜 흔들리는지는 설명하지 못한다. 기다려야 하는 대상은 시간이 아니라 상태였다.
Case 2. 성공 toast를 검증하면 테스트가 예민해진다.
다음 문제는 API 응답 이후 작업자가 할당되는 부분에서 발견되었다. 버튼을 누른 뒤 API 호출이 끝나고 테스트는 성공 메시지를 기다렸다.
await expect(page.getByText("작업이 할당되었습니다.")).toBeVisible({
timeout: 15_000,
});이 코드는 자연스러워 보였다. 사용자가 작업자 할당에 성공하면 성공 메시지를 보니까, 테스트도 그 메시지를 확인하면 된다고 생각했다. 하지만 CI에서는 실패했다.

CI에서는 아래와 같은 에러를 내뱉었다.
Error: expect(locator).toBeVisible() failed
Locator: getByText('작업이 할당되었습니다.')
Expected: visible
Timeout: 15000ms로그를 봤는데 이 실패는 판단하기 애매했다.
- 작업자 할당 API가 실패한건지?
- toast가 떴다가 사라진건지?
- CI가 그 짧은 순간을 놓친건지?
이런 실패는 테스트 신뢰도를 떨어뜨린다. 빨간색이 떴는데 제품이 깨진 건지, 테스트가 예민한 건지 바로 알 수 없기 때문이다.
그래서 성공 toast를 핵심 검증으로 삼지 않는 쪽으로 생각을 바꿨다. toast는 사용자에게 알려주는 UI일 뿐이고, 업무적으로 중요한 상태는 따로 있다. 작업자 할당이 성공했다면 작업자 정보가 실제 상태에 반영되어야 한다.
예를 들면 이런 검증이 더 낫다.
await expect(page.getByTestId("assigned-worker-name")).toHaveText("worker1");또는 API 응답과 후속 상태를 같이 본다.
const assignResponse = await waitForAssignWorker(page);
expect(assignResponse.ok()).toBeTruthy();
await expect(page.getByTestId("worker-assigned-status")).toBeVisible();잠깐 나타나는 메시지보다, 성공 이후 남아야 하는 상태를 검증하자.
다음은 내가 겪은 사례는 아니지만 이외의 flaky 테스트를 줄이는 Case들을 정리해보자.
Case 3. waitForResponse를 늦게 걸면 응답을 놓친다
패킹 쪽에서는 네트워크 대기 timeout이 발생했다.
b2b-vehicle-packing.helper.ts:79
TimeoutError: page.waitForResponse: Timeout 30000ms exceeded처음 본 원인은 서버처럼 보였다. 실제 dev 서버를 바라보고 있었고, CI 환경이면 서버 응답이 느릴 수도 있으니까.
그런데 테스트 코드를 다시 보니 다른 문제가 있었다. 클릭을 먼저 하고, 그다음에 응답을 기다리는 패턴이 있었다.
await page.getByTestId("submit-button").click();
const response = await page.waitForResponse((response) =>
response.url().includes("/api/order"),
);이 코드는 응답이 늦게 오면 동작한다. 하지만 응답이 빠르게 끝나면 테스트가 기다리기 시작하기 전에 이미 지나가버릴 수 있다. 그러면 서버는 정상 응답했는데 테스트는 계속 기다리다가 timeout이 난다.
그래서 클릭과 응답 대기를 같은 타이밍에 시작하도록 바꿔야 했다.
const [response] = await Promise.all([
page.waitForResponse((response) =>
response.url().includes("/api/order") && response.status() === 200,
),
page.getByTestId("submit-button").click(),
]);
expect(response.ok()).toBeTruthy();이 차이는 작아 보이지만 CI에서는 꽤 중요했다. E2E 테스트는 사용자의 액션과 그 액션으로 발생하는 네트워크 이벤트를 명확히 묶어야 한다.
이후에는 waitForResponse를 쓸 때마다 두 가지를 확인했다.
- 이 응답은 어떤 사용자 액션으로 발생하는가?
- 대기를 액션보다 먼저 등록하고 있는가?
Case 4. 첫 번째 row를 믿으면 다른 데이터로 테스트한다
테스트 데이터도 문제였다.
화면에 있는 첫 번째 row를 클릭하는 방식은 편해 보였다.
await page.locator(".ag-row").first().click();로컬에서는 잘 됐다. 그런데 이 방식은 dev 서버를 바라보는 순간 불안정해진다. 목록의 첫 번째 데이터는 언제든 바뀔 수 있다. 다른 사람이 만든 데이터가 있을 수도 있고, 정렬이 달라질 수도 있고, CI에서 실행할 때는 로컬과 전혀 다른 상태일 수도 있다.
B2B 출고 E2E는 특히 이 문제가 컸다. 테스트는 주문을 생성하고, 그 주문으로 피킹 작업을 만들고, 그 작업번호로 PDA에서 피킹해야 한다. 그런데 중간에 첫 번째 row를 클릭하면 방금 만든 주문이 아니라 기존 주문으로 테스트가 흘러갈 수 있다.
그렇게 되면 테스트가 통과해도 문제다. 내가 검증하려던 플로우를 검증한 게 아니기 때문이다.
그래서 생성한 주문번호를 저장하고, 그 값을 기준으로 다음 단계를 진행하도록 바꿨다.
const orderNumber = await createB2BOrder(page);
await page.getByTestId("order-search-input").fill(orderNumber);
await page.getByTestId("search-button").click();
await page
.getByTestId("b2b-outbound-list-grid")
.locator(".ag-row")
.filter({ hasText: orderNumber })
.first()
.click();작업번호도 같은 방식으로 다뤘다. 피킹 작업을 만들면 그 작업번호를 저장하고, PDA 테스트에서 그대로 사용한다.
이 기준을 세우고 나서 테스트의 의미가 훨씬 명확해졌다.
E2E는 화면에 있는 아무 데이터가 아니라, 테스트가 만든 데이터를 끝까지 따라가야 한다.
Case 5. selector는 구현 디테일이 아니라 사용자 행동을 기준으로 잡는다
selector도 많이 흔들렸다.
처음 작성한 테스트는 DOM 구조나 텍스트로 요소를 찾았다. 심지어 아이콘 버튼을 찾기 위해 SVG path를 기준으로 잡은 코드도 있었다.
page.locator('svg path[d="M0.5 8l7.5 7.5v-4.5h8v-6h-8v-4.5z"]');이건 거의 구현 디테일 테스트다. 아이콘이 바뀌거나 라이브러리가 바뀌면 기능은 그대로인데 테스트만 깨진다.
텍스트 기반 selector도 항상 안전하지 않았다.
Error: expect(locator).toBeVisible() failed
Locator: getByText('B2B 출고 목록')이 실패를 보고 나서 질문을 바꿨다.
내가 정말 확인하고 싶은 게 페이지 제목인가?
아니었다. 확인하고 싶은 건 이 화면에서 액션 버튼을 누르고 주문 생성 플로우를 시작할 수 있는지였다. 그러면 제목 텍스트보다 액션 버튼이나 그리드처럼 플로우의 기준이 되는 지점을 잡는 게 낫다.
그래서 주요 액션에는 data-testid를 붙였다.
<button data-testid="b2b-create-order-button">
B2B 신청
</button>테스트는 이렇게 읽는다.
await page.getByTestId("b2b-create-order-button").click();물론 모든 요소에 data-testid를 붙이는 건 좋지 않다. 하지만 사용자의 핵심 액션, 생성한 데이터 조회, 다음 단계로 넘어가는 지점에는 명시적인 test id가 훨씬 안정적이었다.
이후 selector를 잡을 때는 DOM 구조보다 사용자 행동을 먼저 봤다.
Case 6. 테스트 편의를 위해 제품 코드를 바꾸면 안 된다
이번 작업에서 가장 크게 반성한 부분도 있다.
E2E를 쉽게 만들려고 제품 코드에 테스트 전용 플래그를 넣는 방향이 잠깐 들어갔다.
if (window.cgkre2e) {
// 테스트 전용 처리
}의도는 테스트 안정화였다. 하지만 이 방식은 위험했다. 실제 사용자가 타는 경로와 테스트가 타는 경로가 달라진다. 그러면 E2E라고 부르기 애매해진다.
더 큰 문제는 테스트 편의를 위한 변경이 실제 화면에 영향을 줄 수 있다는 점이었다. 실제로 테스트를 위해 넣은 코드가 기존 그리드나 스타일에 영향을 줄 수 있다는 걸 확인했다. 그 순간 이 방향은 잘못됐다고 판단했다.
E2E 테스트는 제품 코드 위에 얹혀야지, 제품 코드가 테스트를 위해 우회로를 제공하면 안 된다.
필요한 건 테스트 전용 코드가 아니었다.
- 안정적인 selector
- 생성한 데이터를 따라가는 방식
- 명확한 네트워크 대기
- 실패 시 확인 가능한 trace와 report
이쪽으로 해결하는 게 맞았다.
실제 서버 E2E와 mock 테스트를 나누기
E2E라면 당연히 실제 dev 서버를 봐야 한다고 생각했다. 하지만 모든 케이스를 실제 서버로 검증하려고 하면 테스트가 서버 데이터에 너무 강하게 묶인다.
주문 생성부터 패킹까지 이어지는 큰 플로우는 실제 서버를 보는 게 맞다. 여러 시스템이 실제로 연결되는지를 확인해야 하기 때문이다.
반대로 빈 목록, validation error, 버튼 disabled 상태, 특정 실패 메시지 같은 케이스는 mock 기반 테스트가 더 맞을 수 있다. 이런 상태를 dev 서버 데이터로 안정적으로 만들기는 어렵다.
그래서 역할을 나눠서 생각하게 됐다.
- 실제 서버 E2E: 주문 생성, 피킹, 패킹 같은 핵심 업무 흐름
- mock 기반 테스트: 빈 상태, 에러 상태, validation 같은 UI 조건
- 단위/통합 테스트: mapper, DTO 변환, 도메인 로직
모든 걸 E2E로 올리면 테스트는 느려지고 예민해진다. E2E는 적게 가져가되, 실패했을 때 의미 있는 흐름 위주로 남기는 게 낫다.
CI에서 실패 원인을 볼 수 있게 아티팩트를 심기
E2E 실패는 로그만으로 원인을 알기 어렵다.
selector 문제인지, 서버 데이터 문제인지, 로딩이 덜 끝난 건지, 실제 제품 버그인지 바로 구분하기 어렵다. 그래서 실패했을 때 남는 자료가 중요했다.
Playwright에서는 최소한 이 정도는 켜두는 게 좋았다.
use: {
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
}CI에서는 HTML report를 artifact로 올린다.
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/이렇게하면 아래와 같이 아티팩트가 업로드된다.

추가로 Slack 알림도 붙였다. 중요한 건 실패를 팀이 바로 볼 수 있게 만드는 것이다. 자동화는 테스트를 실행하는 것에서 끝나지 않는다. 실패했을 때 원인을 빠르게 좁힐 수 있어야 한다.
마치며
E2E를 붙이면서 느꼈던 점이 Playwright는 좋은 도구지만, 테스트가 무엇을 기다려야 하는지, 어떤 데이터를 기준으로 삼아야 하는지, 어떤 상태를 성공으로 볼지는 결국 사람이 정해야 한다.
그 기준을 한번 정리해보면
visible보다 조작 가능한 상태를 기다린다.- toast보다 최종 상태를 검증한다.
- 클릭과 네트워크 대기는 같이 시작한다.
- 첫 번째 row가 아니라 내가 만든 데이터를 따라간다.
- DOM 구조보다 사용자 행동 기준 selector를 쓴다.
- 제품 코드에 테스트 전용 우회로를 넣지 않는다.
- 실제 서버 E2E와 mock 테스트의 역할을 나눈다.
- 실패하면 trace, screenshot, video, report를 남긴다.
결국 E2E 테스트의 목표는 항상 초록색을 만드는 게 아니었다. 실패했을 때 그 실패를 믿을 수 있게 만드는 것이었다. 그래야 테스트가 실제 회귀를 알려주는 신호로 쓰일 수 있다.

마지막으로 E2E 테스트 성공한 기념으로 기록을 남기기