커머스 프론트엔드에 Playwright E2E를 붙이고 GitHub Actions로 자동화했다.
매일 새벽 3시에 4개 브라우저가 알아서 상품 담고 주문서까지 들어가서 118개를 검사한다.
머지하고 뿌듯하게 잤다.
다음 날 아침에 보니 2분 만에 빨갛게 죽어 있었다.
앱 버그면 차라리 나았을 텐데, 범인은 내가 방금 만든 파이프라인 본인이었다.
그것도 물음표 두 개 때문에. 만드는 과정보다 이거 쫓은 과정이 더 웃겨서 그것까지 같이 적는다.

smoke(크로미움 1종)와 새벽에 도는 full(4개 브라우저)로 갈랐다schedule 트리거에는 inputs가 없어서 BASE_URL에 빈 문자열이 들어왔고, ??가 그걸 통과시켰다-export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
+export const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
우리 주문서에서 결제 버튼을 누르면 이런 일이 순서대로 일어난다.
orderId·amount로 PG 결제창을 띄운다amount를 대조한다. 다르면 결제창을 안 띄운다네 단계가 전부 다른 파일, 다른 레이어에 있다. 유닛 테스트는 각 함수가 제 몫을 하는지는
증명한다. 근데 "쿠폰을 고르고 결제를 눌렀을 때 사용자가 본 금액이 청구되는가"는 못 증명한다.
그건 함수의 성질이 아니라 화면과 서버와 브라우저가 같이 만들어내는 성질이니까.
실제로 우리가 맞은 사고도 다 그 틈에서 나왔다.
셋 다 유닛 테스트 100% 통과한 코드에서 나왔다.
처음엔 손으로 했다. 결제 위젯 직전까지 시나리오를 만들어 직접 클릭하고, 깨지는 걸 이슈로 남겼다.
문제는 이게 한 번 하고 끝나는 일이 아니라는 거다. 커머스는 화면 하나 고치면 옆 화면이 깨진다.
쿠폰 표기를 고치면 주문서 총액이 흔들리고, 장바구니 동기화를 손보면 결제 진입이 막힌다.
매번 손으로 전 경로를 다시 밟는 건 불가능하다.
더 큰 문제는 따로 있었다. 손으로 하는 검증은 릴리스 직전에만 일어난다.
그때 발견하면 이미 늦다.
Cypress랑 저울질했다. 셋을 봤다 — 얼마나 쓰이는지, 무료로 어디까지 되는지,
그리고 우리가 제일 자주 깨지는 곳을 볼 수 있는지.
npm 공식 다운로드 API로 직접 찍어봤다 (2026-08-09 ~ 08-15 주간).
| 패키지 | 주간 다운로드 |
|---|---|
@playwright/test | 37,489,737 |
cypress | 6,430,842 |
약 5.8배다. playwright 패키지는 7,082만인데 이건 다른 도구의 의존성으로 딸려 들어가는
몫이 섞여 있어서 뺐다. 테스트 러너로 직접 설치하는 패키지끼리 맞춘 게 위 숫자다.
직접 확인하려면:
curl -s https://api.npmjs.org/downloads/point/last-week/cypress curl -s "https://api.npmjs.org/downloads/point/last-week/@playwright%2Ftest"
Playwright가 다운로드에서 Cypress를 넘어선 건 2024년 말이고, 이후로 격차가 계속 벌어지고 있다.
생태계 크기는 곧 막혔을 때 검색해서 나오는 답의 양이라 무시할 수 없는 기준이었다.
여기서 갈렸다. Cypress는 병렬 실행에 유료 서비스가 필요하다.
"Running tests in parallel requires the
--recordflag be passed."
— Cypress 공식 문서, Parallelization
--record는 Cypress Cloud로 결과를 보내는 플래그다. 즉 머신을 나눠 돌리려면 Cloud에 붙어야 하고,
무료 플랜은 월 실행 건수 제한이 있다.
우리 full 트랙은 브라우저 4종을 워커 2개로 돌린다. Playwright는 workers와 --shard가
러너에 그냥 들어 있고 돈이 안 든다. 나중에 전용 백엔드가 생기면 워커를 올릴 계획이라,
"확장하려면 결제가 필요한 구조"는 처음부터 피하고 싶었다.
리포트도 같다. Playwright의 trace 뷰어·HTML 리포트는 self-hosted고, Cypress의 대응 기능인
Test Replay는 Cloud 기능이다. 우리는 이미 GitHub 아티팩트 할당량에 막혀 있는데
(6장에 나온다) 여기에 또 다른 외부 의존을 얹고 싶지 않았다.
이 블로그에 Safari 하이드레이션 에러 추적기를 쓴 적이 있다.
iOS Safari가 푸터의 전화번호를 자기 마음대로 tel: 링크로 감싸면서 서버 HTML과 클라이언트
HTML이 어긋났고, 하이드레이션이 전 페이지에서 깨졌던 건이다. Chrome에서는 0건이었다.
그 경험 이후로 "크로미움만 도는 테스트"는 안심이 안 됐다.
Cypress도 WebKit을 지원하긴 한다. 다만 2026년 8월 현재까지도 experimental이고,
쓰려면 설정에 experimentalWebKitSupport: true를 켜야 한다. 그리고 문서에 이렇게 적혀 있다.
"Install the
playwright-webkitnpm package in your repo to acquire WebKit itself."
— Cypress 공식 문서, Launching Browsers
Cypress로 Safari를 보려면 결국 Playwright의 WebKit 빌드를 깔아야 한다.
이걸 보고 나서는 더 고민하지 않았다.
그래서 full 트랙 매트릭스가 이렇게 생겼다.
export const CROSS_BROWSER_PROJECTS = [
CHROMIUM_PROJECT,
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 14"] } },
];
데스크톱 WebKit이랑 iPhone을 굳이 따로 둔 이유도 있다. 사파리 버그는 엔진 차이랑 뷰포트
차이가 따로 논다. 레이아웃은 폭 390에서만 깨지고, 스크롤이나 sticky는 엔진에서만 깨진다.
실제로 이 매트릭스가 하나 잡았다. WebKit에서만 sr-only 체크박스에 check({ force: true })가
상태를 안 바꿨다. 라벨을 클릭하는 걸로 바꿨는데, 생각해보면 그게 실제 사용자 동작이기도 하다.
크로미움만 돌렸으면 못 봤을 버그다.
storageState 하나로 로그인 상태를 파일에 떨궈두고 전 프로젝트가 재사용한다.
projects: [
{ name: "setup", testDir: "./e2e/support", testMatch: /.*\.setup\.ts/ },
...CROSS_BROWSER_PROJECTS.map((p) => ({
...p,
dependencies: ["setup"], // setup 이 먼저 돌고
use: { ...p.use, storageState: STORAGE_STATE }, // 그 결과를 물려받는다
})),
]
118개 테스트가 각자 로그인하면 그것만으로 몇 분이 날아간다.
trace: "on-first-retry", // 재시도 1회차에만. 전부 남기면 수백 MB
screenshot: "only-on-failure",
video: "retain-on-failure",
CI에서 깨진 걸 로컬에서 재현하는 건 지옥이다. trace 뷰어는 실패 시점의 DOM·네트워크·콘솔을
타임라인으로 되감아준다.

한 화면에 다 들어 있다. 위쪽 필름스트립으로 어느 순간에 무슨 화면이었는지 훑고,
왼쪽에서 빨간 액션을 누르면 가운데에 그 시점의 DOM이 그대로 뜬다.
아래는 무엇을 기대했고 무엇을 받았는지, 그리고 10초 동안 몇 번이나 다시 시도했는지의 로그다.
(위 캡처는 설명용으로 단언 하나를 일부러 깨뜨린 것이다. -TRACE-DEMO가 그 흔적)
네트워크 탭도 같이 얼어 있다. 요청 114건이 메서드·상태·크기·소요시간까지 다 남는다.

"화면은 멀쩡한데 왜 실패했지"의 답이 대부분 여기 있다. 이거 없었으면 4장의 버그를 훨씬 늦게 찾았을 거다.
💥 옆길 — 로컬에서는 trace 가 안 생긴다설정이 이렇다.
trace: "on-first-retry", // 재시도 1회차에만
retries: process.env.CI ? 1 : 0, // 로컬은 재시도 0
로컬은 재시도가 없으니 그냥 돌리면 trace.zip 이 아예 안 생긴다.
"실패했는데 trace 가 어디 있지" 하고 한참 찾았다. 명시적으로 켜야 한다.
pnpm exec playwright test -c playwright.smoke.config.ts --trace=on -g "CTA"
pnpm exec playwright show-trace test-results/<...>/trace.zip
CI 는 retries: 1 이라 첫 재시도에서 남는다. 전부 남기면 아티팩트가 수백 MB 가 되니
"한 번 더 시도해도 실패한 것만" 남기는 절충이다.
E2E가 신뢰를 잃는 첫 번째 이유는 flaky고, flaky의 첫 번째 원인은 sleep(1000)이다.
Playwright 액션은 요소가 actionable해질 때까지 알아서 기다린다.
우리 스위트에 waitForTimeout은 한 줄도 없다.
여기까지만 보면 Cypress를 까는 글 같은데, 그건 아니다.
진입 장벽은 Cypress가 확실히 낮다. 브라우저 안에서 도니까 명령이 화면에 딱 붙어 있는
느낌이 있고, time-travel 디버깅은 처음 쓰는 사람한테 훨씬 친절하다.
문서도 좋다 — 이 글 근거 찾을 때 제일 빨리 답 준 것도 Cypress 공식 문서였다(...).
우리가 Playwright를 고른 건 "Cypress가 별로여서"가 아니라
우리가 깨지는 지점이 하필 Safari였고, 워커를 공짜로 늘려야 했기 때문이다.
크로미움만 보면 되는 사내 툴이었으면 반대로 골랐을지도 모른다.
💡 교훈 1: 도구는 "뭐가 제일 유명한가"가 아니라 "우리가 제일 많이 깨진 곳을 볼 수 있나"로 고르면 된다.
우리한테는 그게 WebKit이었다. 다운로드 수는 그다음 근거였지 첫 번째가 아니었다.
E2E를 한 덩어리로 만들면 둘 중 하나가 된다. 너무 느려서 PR을 막거나, 너무 얕아서 아무것도 못 잡거나.
그래서 역할이 다른 두 개로 갈랐다.
| smoke | full | |
|---|---|---|
| 언제 | PR 열려 있는 동안 푸시할 때마다 | 매일 새벽 3시 + 수동 |
| 브라우저 | 크로미움 1종 | 크로미움 / WebKit / Pixel 7 / iPhone 14 |
| 범위 | 홈 → 목록 → 상세 → 담기 → 주문서 → 결제 | smoke 전부 + 쿠폰·품절·결제실패·비회원vs회원 |
| 재시도 | 1회 | 2회 |
| 역할 | PR을 막는 게이트 | 야간 회귀 |
full이 PR을 안 막는 게 중요하다. 안 막으니까 느려도 되고, 느려도 되니까 브라우저를 4개로
넓힐 수 있다. 반대로 smoke는 PR을 막으니 빨라야 하고, 그래서 크로미움 하나만 본다.

상품 데이터를 목으로 못 막는다. 목록이랑 상세가 서버 컴포넌트라 브라우저가 아니라 서버가
백엔드를 부른다. 페이지 레벨 목으로는 가로챌 수가 없어서 스테이징 백엔드를 그대로 쓴다.
문구를 하드코딩 안 한다. 앱이 자체 i18n(KR/EN)을 쓰니까 테스트도 같은 사전에서 문구를 꺼내온다.
안 그러면 번역 한 줄 고칠 때마다 테스트가 깨진다.
짧은 한국어 라벨엔 무조건 exact: true. "담기"로 찾으면 "관심상품 담기"까지 잡히고,
"비밀번호"로 찾으면 aria-label="비밀번호 보기" 토글까지 잡혀서 strict mode로 터진다.
한국어 UI에서 계속 밟는 지뢰라 아예 기본 규칙으로 정했다.
하이드레이션을 명시적으로 기다린다. 이게 제일 값졌다.
/* SSR HTML 이 와 있으면 입력칸은 보이고 enabled 지만, React 가 붙기 전에 fill() 하면
* DOM 값만 바뀌고 컴포넌트 state 는 비어 있다. canSubmit 이 state 를 보므로
* 제출 버튼이 영원히 disabled 로 남는다. actionability 검사로는 안 잡힌다. */
await expect
.poll(() => submit.evaluate((el) =>
Object.keys(el).some((k) => k.startsWith("__reactFiber$"))
), { timeout: 30_000, message: "로그인 폼 하이드레이션 대기" })
.toBe(true);
"값은 들어갔는데 버튼이 잠김"은 실제로 한 번 터뜨려보고 알았다.
Playwright는 요소가 보이고 클릭 가능한지는 봐주지만, React가 그 요소에 붙었는지는 안 봐준다.
E2E가 실제 결제를 태우면 안 된다. 안전 때문만은 아니고 부작용 때문이다.
진짜로 부르면 스테이징에 PENDING 주문이 쌓이고, 쿠폰이 소진되고, 재고가 묶인다.
window.TossPayments를 addInitScript로 먼저 심는다.그리고 "PG 도메인 요청 0건"을 단언하는 테스트를 따로 뒀다. 방어가 뚫리면 그 테스트가 먼저 죽는다.
방어 장치도 검증받아야 하니까.
처음 CI를 붙였을 때 12분 47초가 나왔다. 로그를 뜯어보니
playwright install --with-deps 한 스텝이 626초였다. 전체의 82%.
브라우저 바이너리는 캐시할 수 있는데 apt-get으로 까는 시스템 라이브러리는 매번 새로 깔린다.
캐시로 해결이 안 되는 종류의 시간이었다.
Playwright 공식 컨테이너 이미지로 갈아탔다. 브라우저도 시스템 라이브러리도 이미 들어 있다.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
12분 47초 → 2분 59초.
대신 이미지 태그랑 @playwright/test 버전이 어긋나면 조용히 이상하게 동작한다.
그래서 대조해서 어긋나면 실패시키는 스텝을 넣어뒀다.
HAVE=$(node -p "require('@playwright/test/package.json').version")
if [ "$HAVE" != "$EXPECTED_PLAYWRIGHT_VERSION" ]; then
echo "::error::@playwright/test($HAVE)와 컨테이너 이미지가 어긋납니다."
exit 1
fi
머지한 다음 날 아침, Actions 탭을 열었다. 빨간 X.
솔직히 좀 억울했다. 어제까지 PR에서 초록으로 잘 돌던 애들인데?
"아, 프로덕션 DB에 테스트 계정을 안 만들어놨네."
진짜 그럴듯했다. 야간은 main에서 도니까 프로덕션일 거고, 테스트 계정은 스테이징에만 있으니까.
납득까지 3초 걸렸다. 이제 백엔드분한테 계정 만들어달라고 하면 되겠다 싶었는데,
확인이나 해보자고 연 로그가 이랬다.
[warmup] / 실패(무시): page.goto: Cannot navigate to invalid URL
✘ [setup] › e2e/support/auth.setup.ts:22:6 › authenticate (520ms)
Error: browserContext.addCookies: Cookie should have a url or a domain/path pair

skip이 116건인 것도 눈에 걸렸다. setup이 죽으면 그걸 dependencies로 물고 있는 프로젝트가
전부 통째로 skip된다. 스위트가 "실패"한 게 아니라 시작조차 못 한 모양이었다.
auth.setup.ts:38은 로그인 폼에 가기 전에 언어 쿠키를 심는 줄이다.
520ms 만에 죽었으니 백엔드에 요청 한 번을 못 보냈다. 계정이 있고 없고의 문제가 아니었다.
결정적으로, 4시간 전에 같은 계정으로 smoke가 19개 전부 통과해 있었다.
authenticate도 4.8초에 유유히 지나갔고.
그리고 나중에 알았는데 야간 full은 애초에 프로덕션을 쳐다보지도 않는다.
러너에서 앱을 직접 띄우고 백엔드는 스테이징을 본다. 내 가설은 두 겹으로 틀렸던 셈이다.
30분만 더 확신했으면 백엔드분한테 "프로덕션에 계정 좀 만들어주세요" 하고 있었을 거다.
💡 교훈 2: "그럴듯한 원인"이 떠오르면 그걸 반증할 증거부터 찾는 게 빠르다.
여기선 "4시간 전 같은 계정으로 통과한 런"이 30초 만에 가설을 죽였다.
Cannot navigate to invalid URL. 이건 주소가 틀렸다는 게 아니라 없다는 뜻이다.
워크플로를 봤다.
env:
BASE_URL: ${{ inputs.base_url }} # 수동 실행에서 URL 을 주면 그걸 때린다
inputs.base_url은 수동 실행(workflow_dispatch)의 입력이다.
근데 야간은 schedule로 돈다. schedule 트리거에는 inputs가 아예 없다.
그럼 이 값이 뭐가 될까. undefined가 아니다.
GitHub은 빈 문자열을 "정의된 채로" 넘긴다.
export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
??는 null이랑 undefined만 거른다. 빈 문자열은? 그냥 통과.
이 한 글자 차이로 BASE_URL = ""이 됐다. 여기서부터 도미노가 시작된다.
function isLocalTarget(url: string): boolean {
try {
const { hostname } = new URL(url); // new URL("") 은 throw
return hostname === "localhost" || /* ... */;
} catch {
return false; // ← 여기로 떨어진다
}
}
export const webServer = isLocalTarget(BASE_URL) ? { /* pnpm start */ } : undefined;
isLocalTarget("")이 false → webServer가 undefined → Next 서버가 아예 안 떴다baseURL이 "" → warmup의 page.goto가 Cannot navigate to invalid URLaddCookies({ url: "" }) → Cookie should have a url → setup 실패 → 스위트 전체 중단정리하면 앱 서버가 안 뜬 채로 테스트가 출발했다. 아무것도 없는 허공에 대고
"장바구니 버튼 어딨어?" 하고 물어본 거다. 그러니 6초 만에 끝나지.
실제로 그 6초가 결정적 단서였다. webServer가 정상이었으면 서버 기동을 최대 180초까지
기다렸을 텐데, 로그에 그 시간이 통째로 없었다.
에러 메시지가 원인에서 얼마나 멀어질 수 있는지 보여주는 표본 같은 케이스였다.
화면에 뜬 건 "쿠키에 URL이 없다"인데, 진짜 원인은 세 단계 위 다른 파일,
그것도 다른 언어(YAML) 에 있었다. 이러니 못 찾지.
# e2e-smoke.yml — BASE_URL 을 아예 설정하지 않는다
설정을 안 하니까 undefined가 되고, ??가 정상 동작해서 localhost로 폴백된다.
workflow_dispatch 입력이 있는 full에만 구멍이 있었다.
그리고 이건 스케줄만의 문제도 아니었다. URL 없이 수동으로 돌려도 똑같이 깨진다.
결국 full 트랙은 원격 URL을 손으로 넣어준 경우 말고는 한 번도 제대로 돈 적이 없었다.
"야간 회귀 파이프라인 구축 완료"라고 써놓고 실제로는 아무것도 안 돌고 있었던 거다.
-export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
+export const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
워크플로가 아니라 config를 고쳤다. 빈 값이 들어오는 경로가 CI 말고도 있어서다.
로컬 .env에 BASE_URL=만 적어둬도 똑같이 깨진다. 입구를 막는 것보다 바닥을 튼튼하게 하는 쪽을 골랐다.

검증은 빈 값을 실제로 넣고 설정을 로드해서 했다.
$ BASE_URL="" playwright test --config=playwright.full.config.ts --list --reporter=json
webServer: {"command":"pnpm start","url":"http://localhost:3000","timeout":180000}
이 자리가 사고 당시엔 undefined였다.
그리고 수동 실행 입력을 비워서 사고랑 똑같은 경로로 돌렸다.
Running 118 tests using 2 workers
114 passed / 4 skipped (6.3m)

skip 4건은 토스 테스트 모드 키를 기다리는 PG 결제창 스펙이다.
💡 교훈 3:
??랑||는 "빈 문자열을 유효한 값으로 볼 거냐"에서 갈린다.
환경변수는 거의 항상||가 맞다. CI가 빈 문자열을 주입하는 경로는 생각보다 많다.
??는 사실 곁가지다. 진짜 문제는 파이프라인이 깨진 걸 아무도 몰랐다는 거다.
여기서 좀 창피한 얘기를 해야 한다.
저 워크플로에는 실패하면 Slack으로 알리는 스텝이 이미 있었다. 사고 10시간 전에
main에 들어가 있었고, 주석에는 제가 이렇게 아주 자신 있게 적어놨다.
# 야간 full 은 **아무도 보고 있지 않다.** 알림이 없으면 깨진 채로 며칠이 지난다.
# 그래서 실패는 반드시 critical 로 보낸다.
그런데 슬랙 알림은 오지 않았다. 웹훅 URL을 Secrets에 등록하는 걸 안 했기 때문이다.
액션은 이렇게 생겼다.
const webhook = process.env.SLACK_WEBHOOK_URL?.trim();
if (!webhook) {
console.log("slack-notify: 웹훅이 없어 전송을 건너뜁니다.");
process.exit(0); // ← 조용히 성공
}
웹훅이 없으면 조용히 성공한다. 포크나 개인 환경에서 CI가 괜히 빨개지지 말라고
제가 그렇게 만들어놨다. 그래서 실행 화면에는 이렇게 찍혀 있었다.
✓ Notify Slack (실패)
초록 체크다. 알림을 못 보낸 스텝이, 알림을 못 보냈다는 사실조차 안 알려주고
초록으로 끝나 있었다.
그리고 이 대목에서 좀 소름이 돋았는데, 저는 하이드레이션 글에서
Sentry가 조용히 꺼져 있어서 한 달치 에러를 놓친 이야기를 이미 쓴 적이 있다.
같은 실수를 도구만 바꿔서 또 한 거다. 계측을 붙였다는 것과 계측이 켜져 있다는 건 다른 얘기인데.
아무튼 웹훅부터 등록하고, 알림을 다시 설계했다.
원래는 성공 알림을 일부러 안 보냈다. "매일 오는 초록은 곧 무시되고, 그러면 빨강도 같이
무시된다"는 이유였다. 지금도 그 근거는 맞다고 생각한다.
근데 이번에 깨달았다. 알림이 아예 없으면
파이프라인이 죽어 있는 거랑 돌긴 도는데 통과하는 걸 구분할 수가 없다.
조용한 게 "정상"인지 "고장"인지 모르는 상태로 몇 달을 갈 수도 있다는 뜻이다.
그래서 성공도 보내되 채널을 갈랐다.
| 무엇 | 어디로 |
|---|---|
| 야간 E2E 실패 | 🔴 사람 깨우는 채널 |
| 야간 E2E 성공 | 🟢 기록용 조용한 채널 |
| 프로덕션 배포 | 🚀 / 🔴 |
PR smoke 실패는 안 보낸다. PR 화면에 이미 빨간 X로 보이는데 거기까지 보내면 채널이 PR 노이즈로 찬다.
알림은 아무도 안 보고 있는 시간대랑 이벤트에만 붙였다.
채널을 웹훅으로 가른 것도 나름 만족스럽다. 웹훅은 발급 시점에 채널이 고정되니까,
두 시크릿에 같은 URL을 넣으면 한 채널로 합쳐지고 나중에 하나만 갈아끼우면 나뉜다.
워크플로는 안 건드려도 된다.

성공/실패 판정을 리포트 파서 결과로 하려다가 손을 멈췄다.
파서는 결과 파일이 없으면 failed=0을 낸다. 그 값으로 가르면
빌드가 깨져서 테스트가 아예 안 돈 실행이 "통과 🟢"로 둔갑한다.
사고 당일 실행이 정확히 그 형태였다. 그대로 갔으면
사고를 은폐하는 알림을 만들 뻔했다. 두 번 당할 뻔한 거다.
# ❌ steps.pw.outputs.failed == '0' 테스트가 안 돈 것도 0이다
# ✅ job.status == 'success' 앞선 스텝의 실패를 그대로 반영한다
severity: ${{ job.status == 'success' && 'success' || 'critical' }}
"실패가 0건"이랑 "성공했다"는 다른 명제다.
장바구니 초기 동기화 레이스. 삭제 직후 뒤늦게 도착한 GET 응답이 지운 항목을 되살렸다.
사람이 손으로 클릭할 땐 타이밍이 안 맞아서 거의 안 보이는데, 봇은 빠르니까 매번 재현했다.
쿠폰 select랑 마일리지 입력에 접근성 이름이 없었다. getByLabel로 못 찾아서 알았다.
두 번째가 좀 의미 있었다. 테스트가 못 찾는 요소는 스크린리더도 못 찾는다.
접근성은 보통 따로 감사를 돌려야 나오는데, 역할·이름 기반 셀렉터를 쓰면 E2E가 그 검사를 겸한다.
공짜로 딸려온 이득이었다.
가져가실 건 세 줄이면 충분하다.
?? 말고 ||. 이거 하나만 챙기셔도 이 글 값은 한다난 "야간엔 아무도 안 보니까 알림이 생명"이라고 주석에 대문짝만하게 써놓고,
그 알림의 웹훅 등록을 안 한 채로 머지했다. 그래서 알림이 안 왔고, 알림이 안 왔다는 사실도
안 알려주고 초록 체크로 끝났다. 심지어 그 "조용히 성공하기"도 제가 짠 코드다.
3단 콤보로 제 발등을 찍었다.
배운 게 있다면 CI는 "설정"이 아니라 코드다.
근데 이상하게 YAML은 코드처럼 안 느껴진다. 타입 체크도 안 붙고, 린트도 안 걸리고,
빈 문자열 하나 흘러들어가는 걸 아무도 안 막아준다. 유일한 검증은 실제로 돌려보는 것뿐인데,
야간 스케줄은 하루에 한 번밖에 안 돈다. 그래서 조용히 오래 깨져 있기 딱 좋다.
혹시 "우리 CI 잘 돌고 있는데?" 싶으시면, 오늘 한 번 일부러 깨뜨려 보시길 권합니다.
알림이 진짜 오는지, 아니면 초록 체크만 뜨고 마는지. 저처럼 3단 콤보 맞기 전에요...ㅎㅎ
?? 때문에 당해보신 분, 아니면 "알고 보니 계측이 꺼져 있었다" 류의 경험 있으신 분
댓글로 공유해주세요.🙌