PWA 설치 프롬프트를 확인하는 Playwright 테스트가 실패한 원인은 앱 결함이 아니라 테스트가 합성 beforeinstallprompt 이벤트를 너무 일찍 보낸 것이었다. 앱이 이벤트 리스너를 붙이기 전에 이벤트가 먼저 도착했고, 그래서 앱은 설치 프롬프트를 붙잡지 못했다. 수정에서는 제품 코드를 건드리지 않고, 앱이 /sw.js를 등록하는 순간을 관찰한 뒤에 이벤트를 보내도록 테스트만 고쳤다.

이 글은 한 웹 앱 저장소에서 테스트 전용 PR 하나로 처리한 일을 기록한 것이다. 이 진단과 수정은 내가 운영하는 코딩 에이전트 작업의 기록을 바탕으로 정리했다. 진단 수치는 모두 진단 기록에서 가져왔고, PR은 2026년 10월 14일(UTC 기준)에 병합됐다. 마지막 절에 적었듯이, 이 수정을 뒷받침하는 증거는 내 로컬에서 반복 실행한 결과뿐이다.

어떤 테스트가 어떻게 실패했나

실패한 테스트의 이름은 root captures an install prompt before client navigation to More이고, 스펙 파일 design-pwa.spec.ts의 139번째 줄에 있다. 테스트 이름으로 읽으면, 루트 화면이 설치 프롬프트를 먼저 붙잡아 두고 그다음 클라이언트 내비게이션으로 "더 보기" 화면에 가는 흐름을 확인하는 테스트다. 이 해석은 이름에서 읽은 것이고, 단언의 세부 내용은 내 기록에 없다. 같은 스펙의 "R5" 묶음에는 테스트가 하나 더 있다. 테스트 이름("R5 standalone launch hides the install entry even with a captured prompt")으로 읽으면, 프롬프트를 붙잡은 상태에서도 독립 실행(standalone) 모드로 켜면 설치 항목이 숨겨지는지를 보는 테스트다. 이것도 이름에서 읽은 해석이다.

처음에는 간헐 실패로 보였지만, 진단 때 기록된 실행은 대부분 실패였다. 진단용 헤드에서 이 테스트가 실패했고, 같은 작업 트리에서 다시 돌린 네 번도 모두 실패했다. 다른 PR의 헤드에서도 같은 테스트가 실패했다. 반면 그보다 앞선 실행 하나는 1.6초에 통과했고, 그때 부하 평균은 6~8이었다.

이 기록에서 읽을 수 있는 것과 읽을 수 없는 것을 나눠 둔다. 확인된 사실은 같은 테스트가 어떤 때는 통과하고 어떤 때는 계속 실패했다는 점이다. 부하 평균이 6~8일 때도 통과했으므로, 부하만으로 실패를 설명할 수는 없다. 어떤 조건이 통과와 실패를 갈랐는지는 기록에 없다. 그래서 나는 조건을 찾지 않고, 실패한 실행의 타임라인으로 순서 자체를 확인하기로 했다.

트레이스에서 먼저 배제한 원인

간헐 실패를 만나면 나는 환경 탓을 먼저 의심한다. 개발 서버가 다시 로드됐거나, 의존성 최적화가 중간에 끼어들었거나, 하이드레이션이 깨졌을 수 있기 때문이다. 기록상 실패한 실행의 트레이스에서 이 후보들이 하나씩 확인됐다.

실패 트레이스에서 확인한 원인 후보와 결과
원인 후보트레이스에서 본 것판정
개발 서버(vite) 재로드재로드 기록 없음배제
의존성 재최적화"new dependencies optimized" 메시지 없음배제
페이지 오류페이지 오류 없음배제
하이드레이션 오류하이드레이션 오류 없음배제
이벤트와 리스너의 순서이벤트를 보내는 호출이 서비스 워커 요청보다 먼저 일어남다음 절에서 확인

네 후보는 모두 트레이스에 나타나지 않았다. 그래서 시간 순서를 직접 확인하는 쪽으로 넘어갔다.

타임라인: 이벤트가 리스너보다 앞섰다

앱의 루트 효과는 먼저 설치 프롬프트 캡처 함수(captureInstallPrompt)를 호출한다. 그다음 /sw.js를 지금 또는 load 이벤트 때 등록한다. 테스트는 페이지를 연 뒤 page.evaluate로 합성 beforeinstallprompt 이벤트를 보낸다. 그러니 테스트의 이벤트 발송이 이 효과보다 빠르면, 보낸 이벤트를 받을 리스너가 아직 없다.

실패한 두 실행에서 컨텍스트 시작 기준으로 두 시각을 읽었다. 단위는 ms다.

실패한 실행의 이벤트 발송 시각과 서비스 워커 요청 시각(컨텍스트 시작 기준, ms)
실행이벤트를 보내는 evaluate/sw.js 요청차이(내가 계산)
진단 실행23542545191
다른 PR 헤드 실행30823774692

아래 그림은 이 순서를 단계로 펼친 것이다.

테스트는 페이지를 연 뒤 page.evaluate로 합성 beforeinstallprompt 이벤트를 보냈고, 보낸 시각은 진단 실행에서 2354ms, PR 헤드 실행에서 3082ms였다. 앱은 captureInstallPrompt로 리스너를 붙인 다음 /sw.js를 요청했고, 요청 시각은 2545ms와 3774ms였다. 리스너는 /sw.js 요청보다 먼저 붙으므로 이벤트를 보낼 때 받을 리스너가 아직 없었을 가능성이 크다. 다만 리스너가 붙은 시각은 따로 재지 않았다.
테스트가 정한 발송 시점이 앱의 준비보다 앞서면 합성 이벤트를 받을 리스너가 없으므로, 이 간헐 실패는 제품 결함이 아니라 타이밍 경쟁이다.

두 실행 모두 이벤트 발송이 서비스 워커 요청보다 앞섰다. 기록된 결론은 합성 이벤트가 리스너가 생기기 전에 발생했다는 것이다. 제품 결함이 아니라 타이밍 경쟁이다.

이 결론에는 내가 덧붙일 단서가 하나 있다. 표의 두 시각은 이벤트 발송과 서비스 워커 요청이지, 리스너가 붙은 순간을 직접 잰 값이 아니다. 리스너는 서비스 워커 요청보다 앞서 붙으므로, 이벤트가 요청보다 먼저였다는 사실은 리스너보다도 먼저였을 가능성을 강하게 가리킨다. 다만 리스너 부착 시각을 따로 찍지는 않았다. 그래서 "순서가 간접 증거로 확인됐다"고 쓰는 것이 정확하다.

한 가지 더, 이 사례에서는 테스트가 이벤트를 보내는 시점을 정한다는 점을 짚어 둔다. MDN은 이 이벤트가 브라우저가 사이트를 PWA로 설치할 수 있다고 판단할 때 발생하며, 발생 시점이 보장되지 않고 보통 페이지 로드 때라고 설명한다. 이 사례의 테스트는 합성 이벤트를 보내는 시점을 직접 정하므로, 앱이 준비되기 전에 보낼 수 있다.

합성 이벤트는 받는 쪽이 준비됐는지 기다려 주지 않는다. 보내는 시점을 정하는 쪽이 준비 신호를 확인해야 한다.

고정 대기 대신 서비스 워커 등록을 기다렸다

가장 쉬운 수정은 goto 뒤에 잠깐 쉬는 것이다. 하지만 그 값은 부하에 따라 달라지는 시간에 기대는 추측이다. 진단 기록에서 두 실행의 차이도 191ms와 692ms로 크게 달랐다. 어떤 값을 골라도 더 느린 실행이 나오면 다시 어긋난다. 수정에서는 시간을 고르는 대신 앱이 직접 내는 신호를 기다리는 쪽을 택했다.

두 방식을 나란히 놓은 비교 그림이다. 왼쪽 고정 대기 열은 goto 뒤에 잠깐 쉬는 가장 쉬운 수정에서 시작한다. 두 실행의 간격이 191ms와 692ms로 달랐다. 고정 대기 값은 이 간격을 넘어야 한다. 오른쪽 서비스 워커 등록 신호 열은 앱이 직접 내는 /sw.js 등록을 기다린다. 같은 루트 효과가 캡처 함수를 호출한 다음에 등록하므로, 등록이 관찰되는 시점에는 리스너가 이미 붙어 있어 이벤트가 리스너를 앞설 수 없다. 강조된 칸은 /sw.js 등록 대기다.
추측한 대기 시간 대신 앱의 실행 순서가 보장하는 신호를 기다려야 설치 프롬프트 이벤트가 리스너보다 먼저 오는 일을 구조적으로 막을 수 있다는 것이 이 수정의 핵심이다.

신호로 쓴 것이 /sw.js 등록이다. 같은 루트 효과가 캡처 함수를 호출한 다음에 서비스 워커를 등록하므로, 등록이 관찰된 시점에는 리스너가 이미 붙어 있다. 등록을 기다리면 이벤트가 리스너보다 앞서는 일이 순서상 일어날 수 없다. 이 수정은 테스트 파일 하나(+18/-2)만 바꿨고 제품 코드는 그대로다.

실제로 한 일을 단계로 적으면 다음과 같다.

  1. 도우미 함수 openForInstallPrompt를 새로 만들었다.
  2. 초기화 스크립트가 서비스 워커 register 호출을 감싼다. 앱이 /sw.js를 등록하면 페이지 쪽 약속(Promise)이 이행되게 했다.
  3. 도우미는 /를 연 뒤 그 약속을 기다린다.
  4. R5 테스트 두 개가 프롬프트를 보내기 전에 /를 직접 여는 대신 이 도우미를 쓴다.

아래 코드는 이 원리를 보이려고 내가 다시 쓴 예시이며 실제 diff가 아니다. 이 예시를 따로 실행해 보지는 않았다.

async function openForInstallPrompt(page: Page) {
  await page.addInitScript(() => {
    const sw = navigator.serviceWorker;
    const original = sw.register.bind(sw);
    (window as any).__swRegistered = new Promise<void>((resolve) => {
      sw.register = (...args) => {
        resolve();               // 앱이 등록을 호출한 순간
        return original(...args);
      };
    });
  });
  await page.goto("/");
  await page.evaluate(() => (window as any).__swRegistered);
}

초기화 스크립트는 페이지 스크립트가 돌기 전에 실행되어야 의미가 있다. Playwright 문서는 page.addInitScript가 문서가 만들어진 뒤, 문서의 스크립트가 실행되기 전에 평가된다고 설명한다. 그래서 앱이 register를 부르기 전에 감싸 둘 수 있다. 같은 문서에는 컨텍스트와 페이지에 설치한 여러 초기화 스크립트의 실행 순서가 정의되어 있지 않다는 주의도 있다. 그래서 감싸는 코드는 한 스크립트 안에 둔다.

MDN은 register()가 ServiceWorkerRegistration으로 이행되는 Promise를 돌려준다고 설명한다. 위 예시에서 약속을 이행시키는 시점은 등록이 끝난 때가 아니라 앱이 register를 호출한 때다. 리스너 부착이 이 호출보다 앞선다는 앱 쪽 순서만 필요하므로 호출 시점으로 충분하다고 판단했다.

생각해 볼 수 있는 다른 대안이 하나 더 있다. Playwright의 expect.poll은 값을 반복해서 확인하다가 통과하거나 시간 제한에 닿으면 끝나며, 기본 제한은 5초다. 반복 확인은 고정 대기보다 낫다. 하지만 무엇을 확인할지가 먼저 필요하다. 기록에는 리스너 부착을 바깥에서 읽는 수단이 언급되지 않는다. 그 대신 서비스 워커 등록 호출이 확인할 신호로 쓰였다. 이 비교는 기록된 결정이 아니라 내 해석이다.

주의: 이 방법은 앱의 효과 안에서 "캡처 다음에 등록"이라는 순서가 유지될 때만 맞다. 누군가 두 호출의 순서를 바꾸면 테스트의 가정이 조용히 깨진다.

위 순서 의존은 기록에 있는 사실이 아니라 이 방식의 구조에서 내가 읽은 위험이다. 기록에는 효과가 캡처 다음에 등록한다는 사실만 있고, 이 순서를 보호하는 별도 테스트가 있다는 내용은 없다. 순서가 바뀌었을 때 이 테스트가 실패로 알려 줄지는 확인하지 않았다.

반복 실행으로 얻은 증거와 얻지 못한 증거

수정 뒤에는 Playwright를 design 프로젝트에서 design-pwa.spec.ts 하나에 --repeat-each=5로 돌렸다. 결과는 42 passed, 종료 코드 0이다. 기록에는 명령 전체가 적혀 있지 않아서 아래 명령은 같은 PR 계열 기록의 형식을 따라 내가 다시 구성한 것이다.

npx playwright test --project=design e2e/app/design-pwa.spec.ts --repeat-each=5

42가 5의 배수가 아니라는 점은 짚어 둔다. 이 숫자가 어떤 구성으로 나왔는지는 기록에 적혀 있지 않다. 내가 확실히 말할 수 있는 것은 반복 실행이 실패 없이 끝났다는 것뿐이다.

수정 PR의 검증 결과 요약
항목값
수정 전 같은 테스트의 재실행4번 모두 실패
수정 후 Playwright 반복 실행repeat-each=5, 42 passed, 종료 코드 0
타입 검사와 빌드둘 다 종료 코드 0
node 테스트 스위트1431개 중 1404 통과, 27 실패(이전 단계와 같은 실패 집합)
이 PR이 더한 의도된 RED없음(간헐 실패 수정)
다른 모델의 코드 리뷰 점수8, 9, 8, 8, 9 (기준은 모두 7 이상)

스위트의 실패 27개는 이 PR이 만든 것이 아니다. 앞 단계에서 의도적으로 실패시켜 둔 테스트 21개와 인프라 문제로 이미 실패하던 6개의 합이고, 이름 비교로 확인했다. 병합 뒤 develop에서 돈 CI의 실패 21개도 같은 의도된 실패와 일치한다. 이런 실패를 허용하는 게이트는 의도된 RED가 다른 PR을 막는 교착을 푼 기록에서 다뤘다.

이제 증거의 한계를 결론 앞에 둔다. GitHub CI에는 Playwright가 없고 CI는 node 스위트만 돌린다. 그래서 이 수정을 뒷받침하는 증거는 내 로컬의 --repeat-each=5 실행뿐이다. 5번 반복이 모두 통과한 것은 수정 전 4번 연속 실패와 대비되지만, 경쟁 조건이 사라졌다고 증명하는 근거는 아니다. CI에서 같은 테스트가 계속 통과하는지는 이 구성에서는 볼 수 없다.

공식 문서에서 확인한 것과 내 기록의 관계

글을 쓰며 공식 문서 네 곳을 읽고 기록과 대조했다. 문서는 내 경험의 근거가 아니다. 내가 만든 가정이 문서의 설명과 어긋나지 않는지를 보는 용도다. 아래 표에는 문서가 말한 것과, 그것이 이 사례와 이어지는 지점을 나눠 적었다.

읽은 문서의 설명과 이 사례의 연결
문서문서가 말한 것이 사례와 이어지는 지점
MDN, beforeinstallprompt 이벤트브라우저가 설치 가능하다고 판단할 때 발생하고, 발생 시점은 보장되지 않는다. 지원 범위가 제한적(Chrome, Edge 표시)이라 앱의 설치 UI는 기본적으로 숨기라고 권한다.실제 브라우저의 이벤트는 시점을 믿을 수 없다. 테스트는 합성 이벤트로 시점을 직접 정하므로 준비 신호를 확인할 책임도 테스트에 있다.
Playwright, page.addInitScript문서가 만들어진 뒤 문서의 스크립트가 돌기 전에 평가된다. 여러 초기화 스크립트의 순서는 정의되지 않는다.register를 앱보다 먼저 감쌀 수 있다. 실제 수정이 그렇게 구성된 이유는 기록에 없다. 문서의 주의에 따라 한 스크립트에 모으기를 권장한다.
MDN, ServiceWorkerContainer.register()ServiceWorkerRegistration으로 이행되는 Promise를 돌려준다. 보안 컨텍스트(HTTPS 또는 localhost)에서만 쓸 수 있다.등록 호출을 신호로 삼았으므로, 이 신호는 앱이 실제로 등록을 시도하는 환경에서만 나온다.
Playwright, expect.poll동기 단언을 비동기 폴링 단언으로 바꾸며, 통과하거나 시간 제한(기본 5초)에 닿을 때까지 다시 확인한다.폴링도 확인할 값이 있어야 쓸 수 있다. 이 앱에는 그 값이 없어서 등록 호출을 만들었다.

문서가 알려 주지 않는 부분도 있다. 이 앱에서 리스너가 몇 ms에 붙는지, 등록이 load 이벤트로 미뤄지는 경우 테스트가 얼마나 더 기다리게 되는지는 어느 문서에도 없다. 나도 그 값은 재지 않았다. 이 글의 수치는 모두 실패한 두 실행의 타임라인에서 나온 것이고, 일반적인 앱에 적용되는 값이 아니다.

같은 증상을 만났을 때의 점검 순서

아래는 이 사례에서 내가 이끌어 낸 권장 순서다. 이 순서를 다른 프로젝트에서 시험한 기록은 없다.

  • 실패 트레이스에서 서버 재로드, 의존성 재최적화, 페이지 오류, 하이드레이션 오류부터 지운다. 하나라도 있으면 경쟁 이전에 환경 문제다.
  • 테스트가 보내는 합성 이벤트의 시각과 앱이 리스너를 붙이는 쪽 호출(여기서는 서비스 워커 요청)의 시각을 같은 기준으로 뽑아 순서를 비교한다.
  • 고정 대기를 넣기 전에 앱이 스스로 내는 신호를 찾는다. 리스너를 붙인 뒤에 반드시 일어나는 호출이 있으면 그 호출을 기다린다.
  • 여러 초기화 스크립트를 쓴다면 실행 순서가 정의되지 않는다는 문서의 주의를 지켜, 감싸는 코드를 한 스크립트에 모은다.
  • 수정 뒤에는 반복 실행 횟수와 통과 수를 그대로 적고, 그 증거가 어디서 돌았는지(로컬인지 CI인지)를 함께 쓴다.

이 사례의 교훈은 하나로 모인다. 테스트가 이벤트를 보내는 방식이라면, 받는 쪽이 준비됐다는 신호를 시간이 아니라 앱의 동작에서 찾아야 한다. 다만 그 신호가 앱의 내부 순서에 기대는 만큼, 순서가 바뀔 때를 대비한 별도 점검은 아직 이 사례에 없다. 비슷한 실전 기록은 프로그래밍 카테고리에 모아 둔다. 원인을 가정하고 고치다 막힌 경험은 확증 편향이 디버깅을 망치는 이유에서 따로 다뤘다.

자주 묻는 질문

beforeinstallprompt 테스트가 간헐적으로 실패하면 무엇부터 확인하나?

트레이스에서 재로드와 오류가 없는지 먼저 확인하고, 합성 이벤트를 보낸 시각과 앱이 리스너를 붙이는 시점을 비교한다. 이번 기록에서는 이벤트 발송이 서비스 워커 요청보다 두 실행 모두 먼저였고, 그 사이는 191ms와 692ms였다.

고정 대기 시간을 늘리면 안 되나?

이번 진단에서 두 실행의 간격이 191ms와 692ms로 달랐다. 고정 대기 값은 이 간격을 넘어야 한다. 앱이 리스너를 붙인 뒤에 반드시 일어나는 호출을 기다리면 시간을 고를 필요가 없다.

서비스 워커 등록을 기다리는 방식의 약점은 무엇인가?

앱의 효과 안에서 캡처 다음에 등록한다는 순서에 기댄다. 순서가 바뀌면 테스트의 가정이 깨질 수 있다. 이 순서를 지키는 별도 검사가 있는지는 확인하지 못했다.

이 수정이 CI에서 검증됐나?

아니다. 이 저장소의 CI에는 Playwright가 없어 node 스위트만 돌아간다. 증거는 로컬에서 --repeat-each=5로 돌린 42 passed, 종료 코드 0이다.