Playwright E2E 테스트의 flaky를 줄이면서 배운 것
E2E 테스트를 할 때 발생하는 다양한 flaky 테스트를 해결한 다양한 사례를 공유합니다.
물류 시스템에서는 주문과 재고 데이터의 정합성을 유지하면서 입고·피킹·패킹으로 이어지는 업무가 중간에 끊기지 않도록 만드는 것이 중요합니다.
들어가며
하나의 주문이 생성되면 피킹 작업과 재고가 연결되고, 현장 작업자의 스캔 결과는 다음 작업 단계에 영향을 줍니다. 중간 단계가 누락되거나 같은 요청이 중복으로 처리되면 화면 하나의 오류로 끝나지 않고, 재고 차이와 작업 지연으로 이어질 수 있습니다.
그래서 WMS의 주요 업무를 Playwright E2E 테스트로 자동화하고 있습니다. 주문 생성부터 피킹 작업 생성, 작업자 할당, PDA 피킹과 패킹까지 여러 화면과 시스템을 통과하는 시나리오입니다. 각 화면이 열리는지만 확인하는 것이 아니라, 앞 단계에서 만든 데이터가 다음 단계까지 같은 식별자로 이어지는지도 확인해야 합니다.
로컬에서는 잘 통과하던 테스트가 CI에서 실패했습니다. 같은 테스트를 다시 실행하면 통과할 때도 있었습니다. 실패한 위치도 항상 같지는 않았습니다.
처음에는 CI 환경이 느려서 생긴 문제라고 생각했습니다. timeout을 늘리거나 retry를 추가하면 해결될 것 같았습니다. 원인을 따라가 보니 브라우저 속도만의 문제는 아니었습니다. 테스트가 기다리는 상태와 업무가 완료됐다고 판단하는 기준이 실제 서비스의 흐름과 맞지 않았습니다.
- 버튼은 보였지만 overlay가 클릭을 막고 있었습니다.
- 성공 toast가 사라진 뒤 테스트가 검증을 시작했습니다.
- 응답 대기를 등록하기 전에 네트워크 요청이 끝났습니다.
- 방금 만든 주문이 아니라 목록의 첫 번째 주문을 선택했습니다.
- selector가 사용자 행동이 아니라 DOM 구조를 따라가고 있었습니다.
직접 겪은 로그인과 toast 문제를 시작으로, 같은 E2E 묶음에서 반복될 수 있는 동기화·데이터·selector 문제를 함께 정리했습니다.
Playwright가 대신 정해주지 않는 것
Playwright에는 auto-waiting (새 탭에서 열림)이 있습니다. click()을 실행하면 요소가 보이는지, 안정적인 위치에 있는지, 이벤트를 받을 수 있는지 등을 확인하며 조건이 충족될 때까지 기다립니다. Locator와 trace도 실패를 분석하는 데 유용합니다.
그렇다고 Playwright를 사용하기만 하면 안정적인 테스트가 만들어지는 것은 아닙니다.
Playwright는 이 버튼을 누른 뒤 어떤 서버 상태가 남아야 하는지 모릅니다. 목록에서 어느 행이 방금 테스트가 만든 데이터인지도 모릅니다. 잠깐 나타나는 toast와 실제 업무 완료 상태 중 무엇이 더 중요한지도 판단하지 않습니다.
도구가 기다릴 수 있는 상태와 서비스가 성공으로 정의하는 상태는 다릅니다.
클릭을 막는 overlay
로그인 단계에서 다음과 같은 오류가 발생했습니다.
TimeoutError: locator.click: Timeout 10000ms exceeded
waiting for getByRole('button', { name: 'LOGIN' })
<div class="css-k54rj5">...</div> intercepts pointer events버튼은 visible·enabled·stable 상태였지만 다른 요소가 pointer event를 가로채고 있었습니다.
로그인 버튼은 화면에 보였습니다. 테스트도 버튼이 보이는지 먼저 확인했습니다.
const loginButton = page.getByRole("button", { name: "LOGIN" });
await expect(loginButton).toBeVisible();
await loginButton.click();버튼 위에는 로딩 overlay가 남아 있었습니다. Playwright는 버튼을 찾았지만 overlay가 pointer event를 가로채고 있어 클릭을 실행할 수 없었습니다. 로컬에서는 overlay가 빠르게 사라졌고, CI에서는 조금 더 오래 남았습니다.
이 상황에서 waitForTimeout(3000)을 추가하면 당장은 통과할 수 있습니다.
await page.waitForTimeout(3000);
await loginButton.click();고정 시간은 화면이 준비됐다는 사실을 보장하지 않습니다. 3초보다 빨리 준비되면 필요 없이 기다리고, 3초보다 오래 걸리면 다시 실패합니다.
화면이 조작 가능한 조건을 직접 기다리는 편이 낫습니다. 로딩 overlay가 로그인 준비 상태를 나타낸다면 그 요소가 사라지는 것을 기준으로 삼습니다.
const loadingOverlay = page.getByTestId("login-loading-overlay");
await expect(loadingOverlay).toBeHidden();
await loginButton.click();중요한 것은 대기 시간을 늘리는 것이 아니라, 사용자가 실제로 조작할 수 있는 상태를 테스트에 표현하는 것입니다. force: true로 클릭을 강제하면 사용자가 누를 수 없는 버튼을 테스트만 누르는 상황이 만들어질 수 있습니다.
성공 메시지보다 업무 상태
작업자를 할당한 뒤 성공 toast를 확인하는 코드도 CI에서 자주 실패했습니다.
await expect(page.getByText("작업이 할당되었습니다.")).toBeVisible({
timeout: 15_000,
});API 성공 검증 다음에 toast를 기다렸지만, 제한 시간 안에 해당 요소를 찾지 못했습니다.
이 실패만으로는 원인을 구분하기 어려웠습니다.
- 작업자 할당 요청이 실패했을 수 있습니다.
- 요청은 성공했지만 toast가 이미 사라졌을 수 있습니다.
- toast는 보였지만 테스트가 짧은 노출 시간을 놓쳤을 수 있습니다.
작업자 할당의 목적은 toast를 띄우는 것이 아닙니다. 선택한 작업자가 실제 작업에 반영되는 것이 목적입니다. 성공 여부는 그 결과로 확인할 수 있습니다.
await expect(page.getByTestId("assigned-worker-name")).toHaveText("worker1");
await expect(page.getByTestId("worker-assigned-status")).toBeVisible();API 응답까지 함께 확인하면 실패 지점도 나누어 볼 수 있습니다.
const assignResponse = await waitForAssignWorker(page);
expect(assignResponse.ok()).toBeTruthy();
await expect(page.getByTestId("worker-assigned-status")).toBeVisible();toast 자체가 제품 요구사항이라면 별도 테스트로 검증할 수 있습니다. 다만 작업자 할당 E2E의 유일한 성공 조건으로 삼지는 않습니다. 잠깐 보이는 알림보다 작업 이후에도 남는 업무 상태가 더 강한 근거입니다.
네트워크 응답을 놓치는 순서
다음 코드는 버튼을 누른 뒤 해당 요청의 응답을 기다립니다.
await page.getByTestId("submit-button").click();
const response = await page.waitForResponse((response) =>
response.url().includes("/api/order"),
);서버 응답이 느리면 문제가 드러나지 않습니다. 클릭 직후 응답이 끝나면 waitForResponse()는 이미 지나간 이벤트를 기다리게 됩니다. 서버는 정상적으로 처리했지만 테스트는 새로운 응답이 오기를 기다리다가 timeout으로 끝납니다.
관찰자는 이벤트를 발생시키는 행동보다 먼저 준비되어야 합니다.
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes("/api/order") &&
response.request().method() === "POST",
),
page.getByTestId("submit-button").click(),
]);
expect(response.ok()).toBeTruthy();Promise.all()의 배열 순서가 네트워크 순서를 만드는 것은 아닙니다. 중요한 부분은 waitForResponse()가 Promise를 만들면서 listener를 먼저 등록하고, 그다음 클릭이 요청을 발생시킨다는 점입니다.
응답 성공과 화면 반영도 같은 상태가 아닙니다. API가 성공한 뒤 React가 화면을 다시 그리는 시간이 남아 있을 수 있습니다.
expect(response.ok()).toBeTruthy();
await expect(page.getByText(orderNumber)).toBeVisible();네트워크와 UI를 나누어 검증하면 어느 구간에서 실패했는지 알기 쉬워집니다.
테스트 데이터의 추적성
개발 서버의 첫 번째 행을 클릭하는 테스트가 있었습니다.
await page.locator(".ag-row").first().click();공용 개발 환경에서 첫 번째 행은 고정된 데이터가 아닙니다. 다른 사람이 새 주문을 만들 수 있고 정렬 조건도 달라질 수 있습니다. 테스트가 시작하기 전에 존재하던 주문을 선택해도 이후 화면이 우연히 정상 동작하면 테스트는 통과합니다.
통과했지만 검증하려던 시나리오를 실행하지 않은 테스트가 됩니다.
B2B 출고 시나리오는 주문 생성, 피킹 작업 생성, 작업자 할당, PDA 피킹으로 이어집니다. 각 단계에서 새로 만들어진 식별자를 다음 단계로 전달해야 같은 업무 객체를 끝까지 따라갈 수 있습니다.
const orderNumber = await createB2BOrder(page);
await page.getByTestId("order-search-input").fill(orderNumber);
await page.getByTestId("search-button").click();
const createdOrderRow = page
.getByTestId("outbound-list")
.locator(".ag-row")
.filter({ hasText: orderNumber });
await expect(createdOrderRow).toHaveCount(1);
await createdOrderRow.click();피킹 작업을 만들었다면 작업번호를 저장하고 PDA 시나리오에서 같은 번호를 사용합니다. 데이터 생성과 조회, 후속 작업이 하나의 식별자로 연결되어야 테스트가 무엇을 검증했는지 설명할 수 있습니다.
테스트가 만든 데이터는 테스트가 끝날 때까지 놓치지 않습니다.
selector에 담아야 할 의미
DOM 구조를 그대로 selector로 사용하면 UI 구현이 바뀔 때마다 테스트가 깨집니다.
page.locator('svg path[d="M0.5 8l7.5 7.5v-4.5h8v-6h-8v-4.5z"]');이 selector는 사용자가 무엇을 누르는지 설명하지 못합니다. 아이콘의 SVG path가 조금만 바뀌어도 기능과 관계없이 실패합니다.
가능하면 사용자가 인식하는 역할과 이름을 우선합니다.
page.getByRole("button", { name: "출고 신청" });
page.getByLabel("주문번호");다국어 화면이거나 같은 이름의 요소가 반복되어 role만으로 대상을 구분하기 어려운 경우에는 업무적으로 중요한 지점에 data-testid를 둘 수 있습니다.
<section data-testid="outbound-order-list">
<OrderGrid />
</section>const orderList = page.getByTestId("outbound-order-list");
await orderList.getByRole("button", { name: "출고 신청" }).click();data-testid가 사용자 행동 기반 selector인 것은 아닙니다. 테스트와 제품 사이에 둔 명시적인 식별 계약입니다. role과 label로 의미를 표현할 수 있을 때는 그쪽을 먼저 쓰고, 화면의 업무 영역이나 생성 데이터를 안정적으로 추적해야 할 때 제한적으로 사용합니다.
실제 서버와 mock의 역할
모든 상태를 실제 개발 서버로 만들려고 하면 E2E가 환경 데이터에 강하게 묶입니다. 빈 목록, 특정 validation 오류, 권한별 disabled 상태를 만들기 위해 서버 데이터를 계속 조작해야 할 수도 있습니다.
반대로 모든 API를 mock하면 여러 시스템이 실제로 연결되는지 확인할 수 없습니다.
테스트 목적에 따라 역할을 다음처럼 나눠 볼 수 있습니다.
| 테스트 | 검증할 대상 |
|---|---|
| 실제 서버 E2E | 주문 생성부터 피킹·패킹까지 이어지는 핵심 업무 흐름 |
| mock 기반 브라우저 테스트 | 빈 상태, 오류 상태, validation, 버튼 상태 |
| 단위·통합 테스트 | mapper, DTO 변환, 계산과 도메인 규칙 |
실제 서버 E2E는 적게 유지하더라도 서비스의 핵심 경로를 대표해야 합니다. mock 기반 테스트는 서버에서 만들기 어려운 조건을 빠르고 결정적으로 재현하는 데 사용합니다.
이름도 구분하는 편이 좋습니다. 백엔드를 mock한 브라우저 테스트를 전체 시스템 E2E와 같은 의미로 부르면 테스트가 보장하는 범위가 흐려집니다.
실패를 조사할 수 있는 CI
E2E 실패 로그에는 결과만 남는 경우가 많습니다.
expect(locator).toBeVisible() failed이 한 줄만으로는 selector가 잘못됐는지, overlay가 남았는지, API가 실패했는지, 다른 데이터가 선택됐는지 알기 어렵습니다.
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/테스트 성공 여부와 관계없이 report를 artifact로 남겨 실행이 끝난 뒤에도 실패 상황을 확인할 수 있게 했습니다.
Trace Viewer (새 탭에서 열림)에서는 action 전후의 DOM, locator, network와 screenshot을 함께 볼 수 있습니다. 실패를 무조건 retry하는 것보다 첫 실패의 자료를 남기는 편이 원인을 찾는 데 도움이 됩니다.
자동화는 테스트를 실행하는 데서 끝나지 않습니다. 실패한 사람이 다음 조사 지점을 바로 찾을 수 있어야 합니다.
정리
이번 작업에서 정리한 기준은 다음과 같습니다.
- 고정 시간보다 조작 가능한 화면 상태를 기다립니다.
- 잠깐 보이는 성공 알림만으로 업무 성공을 판정하지 않습니다.
- 네트워크 listener는 요청을 발생시키는 행동보다 먼저 등록합니다.
- API 응답과 DOM 반영을 각각 검증합니다.
- 공용 환경의 첫 번째 데이터 대신 테스트가 만든 데이터를 추적합니다.
- role과 label을 우선하고
data-testid는 필요한 경계에 사용합니다. - 제품 동작을 바꾸는 테스트 전용 분기를 만들지 않습니다.
- 실제 서버 E2E와 mock 기반 브라우저 테스트의 책임을 나눕니다.
- 실패 당시의 trace, screenshot, video와 report를 남깁니다.
flaky 테스트를 단순히 “가끔 실패하는 테스트”로만 보면 timeout과 retry가 먼저 떠오릅니다. 실제로는 테스트가 기다리는 상태, 사용하는 데이터, 관찰하는 이벤트가 서비스의 동작과 어긋난 경우가 많았습니다.
E2E 테스트의 가치는 항상 통과하는 데 있지 않습니다. 실패했을 때 제품의 회귀인지 테스트의 문제인지 구분할 수 있어야 합니다. 빨간색 결과가 조사할 가치가 있는 신호로 남을 때 E2E를 믿고 사용할 수 있습니다.