「프론트엔드 테스트 제대로 하기」 시리즈의 12편입니다. 전체 목차 보기 · 용어집

flaky 테스트 — 코드는 그대로인데 어떤 날은 통과하고 어떤 날은 실패하는 테스트. E2E를 처음 도입한 팀이 가장 먼저 앓는 병이고, 방치하면 “또 걔야? 그냥 다시 돌려"라는 무서운 문화로 이어집니다. 다행히 원인은 대부분 하나예요: 너무 일찍, 혹은 잘못된 방법으로 요소를 찾는 것. 이 글에서 Playwright의 로케이터와 auto-wait로 그 뿌리를 뽑습니다.

시리즈로 따라오셨다면 1편에서 예고한 그 녀석과의 정면 승부예요. 검색으로 처음 오셨어도 괜찮습니다 — 예제는 자급자족하고, Playwright 기초가 필요하면 입문 편부터 보세요.

실습 코드: 이 편의 상태가 step-12 태그에 담겨 있습니다(첫 시나리오를 견고한 로케이터로 다듬은 상태 — 픽스처나 axe 같은 뒤 편 코드는 아직 없어요). StackBlitz에서 바로 열기도 됩니다.

오늘 잡아야 할 것들입니다.

  • 로케이터의 지연 평가와 strict mode
  • auto-wait가 해주는 일
  • 견고한 셀렉터 고르는 기준 (실제 겪은 사례 포함)
Playwright 로케이터가 요소가 준비될 때까지 자동으로 기다렸다가 동작하는 흐름을 보여주는 다이어그램
Playwright 로케이터가 요소가 준비될 때까지 자동으로 기다렸다가 동작하는 흐름을 보여주는 다이어그램

실화: 셀렉터 하나로 깨진 테스트

이 데모 repo를 만들며 실제로 겪은 일입니다. 처음엔 이렇게 썼어요.

ts
await page.getByLabel('검색').fill('carol')   // strict mode violation!

Playwright가 이런 에러를 냈습니다.

Error: locator.fill: Error: strict mode violation: getByLabel('검색') resolved to 2 elements:
    1) <section aria-labelledby="search-heading">…</section>
       aka getByRole('region', { name: '사용자 검색' })
    2) <input id="q" value="" type="search" placeholder="이름 또는 이메일"/>
       aka getByRole('searchbox', { name: '검색' })

getByLabel('검색')두 요소와 매칭됐거든요 — 검색 입력창, 그리고 aria-labelledby로 “사용자 검색"이라 이름 붙은 섹션(부분 일치). Playwright의 strict mode는 로케이터가 여러 요소와 매칭되면 즉시 실패시켜요. 조용히 첫 번째를 고르는 대신 문제를 드러내는 거죠.

에러를 자세히 보면 더 친절한 구석이 있습니다 — 매칭된 요소마다 aka ...그 요소를 유일하게 집는 로케이터를 제안해주고 있어요. 즉 해법이 에러 안에 이미 적혀 있는 셈입니다. 2번의 제안을 그대로 받아쓰면:

ts
await page.getByRole('searchbox', { name: '검색' }).fill('carol') // 이제 하나만 매칭됩니다

애매한 셀렉터가 어느 날 갑자기 깨지는 것보다, 처음부터 시끄럽게 실패하는 편이 낫습니다.

strict mode가 여러 매칭을 막는 원리를 보여주는 비교 다이어그램 - 왼쪽 getByLabel('검색')은 aria-labelledby로 사용자 검색이라 이름 붙은 섹션과 실제 검색 입력창 두 요소에 매칭되어 strict mode 위반으로 즉시 실패하고, 오른쪽 getByRole searchbox name 검색은 검색창 하나에만 매칭되어 통과합니다. 조용히 첫 번째를 고르지 않고 시끄럽게 실패시키며 에러가 aka로 좁히는 방법까지 알려준다는 설명이 붙어 있습니다
strict mode가 여러 매칭을 막는 원리를 보여주는 비교 다이어그램 - 왼쪽 getByLabel('검색')은 aria-labelledby로 사용자 검색이라 이름 붙은 섹션과 실제 검색 입력창 두 요소에 매칭되어 strict mode 위반으로 즉시 실패하고, 오른쪽 getByRole searchbox name 검색은 검색창 하나에만 매칭되어 통과합니다. 조용히 첫 번째를 고르지 않고 시끄럽게 실패시키며 에러가 aka로 좁히는 방법까지 알려준다는 설명이 붙어 있습니다

로케이터는 게으르다(좋은 의미로)

page.getByRole(...)이 반환하는 로케이터는 “요소"가 아니라 “찾는 방법“입니다. 만드는 시점엔 아무것도 조회하지 않아요 — 그 요소가 아직 DOM에 없어도 됩니다. 실제로 클릭하거나 단언하는 순간에야 찾고, 심지어 그때마다 새로 찾습니다. 이렇게 조회를 쓰는 순간까지 미루는 성질을 지연 평가(lazy evaluation)라고 부르는데, 섹션 제목의 “게으르다"가 바로 이겁니다.

ts
const row = page.getByRole('listitem').filter({ hasText: 'Alice Kim' })
// ↑ 이 줄에선 아무 일도 안 일어남 — 아직 목록이 로딩 전이어도 OK

await expect(row).toBeVisible()   // 이 시점에 조회 (+ 보일 때까지 재시도)

이 게으름이 왜 축복이냐면 — React가 리렌더링하며 DOM 노드를 갈아치워도, 로케이터는 낡은 참조를 붙들고 있지 않고 쓰는 순간의 화면에서 다시 찾기 때문입니다. “요소를 미리 잡아뒀는데 클릭할 땐 이미 죽은 노드"라는 고전적인 E2E 사고가 원천적으로 안 나요. 참고로 이 filter({ hasText }) 패턴은 입문 편의 첫 시나리오를 다듬은 repo 최종본에서 그대로 쓰고 있습니다 — 이름(텍스트)이 비슷한 행이 늘어나도 견디는 방법이에요.


auto-wait — flaky가 태어나는 곳과 죽는 곳

flaky가 태어나는 장면을 슬로모션으로 봅시다. 우리 대시보드는 접속 후 서버에서 목록을 불러오죠. 그 응답이 빠른 날은 0.1초, 느린 날은 1초입니다. 요소를 “지금 당장” 찾는 방식이라면 — 빠른 날은 통과, 느린 날은 실패. 코드는 한 글자도 안 바뀌었는데요. 이게 flaky의 정체입니다.

고전적 처방은 기다림을 수동으로 심는 것이었어요.

ts
// 이러지 마세요
await page.waitForTimeout(3000)   // "3초면 되겠지"
await page.getByText('Alice Kim').click()

sleep은 두 방향으로 다 나쁩니다. 서버가 3.1초 걸린 날은 그래도 실패하고, 0.1초 걸린 날도 꼬박 3초를 낭비해요. 테스트가 수십 개면 이 낭비가 분 단위로 쌓입니다.

Playwright의 답이 auto-wait입니다. 클릭 같은 동작은 요소가 보이고·활성화되고·움직임이 멎을 때까지 알아서 기다렸다가 실행되고, expect(...).toBeVisible() 같은 웹 우선 단언(web-first assertion)은 조건이 참이 될 때까지 (제한 시간 안에서) 재시도합니다. 웹 우선 단언이란 “한 번 검사하고 끝"이 아니라, 화면이 아직 준비 중이면 조건이 맞을 때까지 되풀이 검사하는 단언이에요. 준비되는 즉시 진행하니 낭비도 없고, 느린 날도 기다려주니 실패도 없어요.

flaky 테스트가 생기는 원리와 auto-wait의 해법을 비교한 타임라인 다이어그램 - 위쪽은 요소를 즉시 찾는 방식으로 서버 응답이 빠른 날은 통과하지만 느린 날은 실패해 같은 코드가 날마다 다른 결과를 내고, 가운데 sleep 3초 방식은 느린 날을 구제하지만 빠른 날에도 3초를 낭비하며, 아래쪽 auto-wait는 준비되는 순간까지만 기다렸다가 진행해 두 문제를 모두 해결합니다
flaky 테스트가 생기는 원리와 auto-wait의 해법을 비교한 타임라인 다이어그램 - 위쪽은 요소를 즉시 찾는 방식으로 서버 응답이 빠른 날은 통과하지만 느린 날은 실패해 같은 코드가 날마다 다른 결과를 내고, 가운데 sleep 3초 방식은 느린 날을 구제하지만 빠른 날에도 3초를 낭비하며, 아래쪽 auto-wait는 준비되는 순간까지만 기다렸다가 진행해 두 문제를 모두 해결합니다

그래서 Playwright에선 임의의 waitForTimeout(sleep)이 거의 필요 없습니다 — 코드에 남아 있다면 대부분 flaky의 씨앗이거나 시간 낭비, 보통은 둘 다예요.


또 접근성이네요. getByRole 기반 로케이터는 flaky도 줄이고 접근성도 검증합니다. 견고함과 포용성이 같은 방향을 봅니다.

flaky 테스트를 test.retry()로 덮는 건, 물이 새는 배에 양동이를 두는 것과 같아요. 잠깐은 되지만… 결국 구멍을 막아야 합니다.


한 장 요약

  • flaky의 주범은 타이밍과 애매한 셀렉터 — sleep(waitForTimeout)을 쓰고 있다면 대부분 flaky의 씨앗
  • 로케이터는 게으르다: ‘찾는 법’만 들고 다니다 쓰는 순간 재조회 — DOM이 바뀌어도 견딤
  • auto-wait: 클릭 전 요소가 준비될 때까지 알아서 기다리고, 웹 우선 단언은 조건이 될 때까지 재시도
  • strict mode: 여러 요소와 매칭되면 조용히 고르지 않고 시끄럽게 실패 — 더 구체적인 role로 좁히라는 신호
  • 리트라이로 flaky를 덮는 건 새는 배에 양동이 — 근본 해법은 처음부터 견고한(role 기반) 로케이터

오늘의 초록불

견고한 로케이터를 익혔으니, 다음은 반복을 줄이는 픽스처와 병렬 실행입니다.

레벨업: flaky 테스트의 원인을 짚고 견고한 로케이터로 고칠 수 있습니다.

다음 편: 픽스처·인증 재사용·병렬 실행

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.