반응형
언어 전환 버튼을 선택하는 코드가 있다
로컬에서는 잘 돌았는데, CI 에서 터졌다
원인을 찾는데 2시간 걸렸다
문제: 버튼 인덱스가 밀린다
기존 코드는 전체 button 목록에서 인덱스로 언어 버튼을 찾았다
# ❌ 기존 코드 — 글로벌 button 인덱스 기반
user_btn_idx = page.evaluate("""
() => {
const btns = Array.from(document.querySelectorAll('button'));
return btns.findIndex(b => b.querySelector('p'));
}
""")
# 언어 버튼 = 아바타 바로 앞 버튼
lang_button = page.locator("button").nth(user_btn_idx - 1)
lang_button.click()
이게 왜 터지냐면
모달이 뜨면 button 이 추가된다
정상 상태: [nav] [nav] [lang] [avatar] → lang = nth(2)
모달 있을 때: [nav] [nav] [modal_close] [modal_ok] [lang] [avatar] → lang = nth(4)
인덱스가 밀리면서 엉뚱한 버튼을 선택한다
원인: 어떤 모달이 끼어드나?
browser_snapshot 으로 확인해보니 2가지가 있었다
| 모달 | 발생 조건 | 추가되는 버튼 |
|---|---|---|
| Covalent Feature Release | 첫 방문 시 팝업 | Close 버튼 |
| 무료 체험 안내 | 특정 조건에서 팝업 | 닫기 + CTA 버튼 |
로컬에서는 이미 닫았던 모달이라 안 뜨고, CI 에서는 매번 fresh session 이라 뜬다
해결: 3가지를 고쳤다
1. 글로벌 인덱스 → 부모 컨테이너 기반 탐색
# ✅ 수정 코드 — 아바타 부모 컨테이너 내에서 sibling 탐색
lang_sibling_idx = page.evaluate("""
() => {
// 아바타 버튼 찾기 (p 태그 포함하는 button)
const avatar = Array.from(document.querySelectorAll('button'))
.find(b => b.querySelector('p'));
if (!avatar) return null;
// 아바타의 부모 컨테이너로 범위 한정
const parent = avatar.parentElement;
const siblings = Array.from(parent.children);
const avatarIdx = siblings.indexOf(avatar);
// 아바타 왼쪽에서 aria-haspopup="menu" 인 버튼 찾기
for (let i = avatarIdx - 1; i >= 0; i--) {
const el = siblings[i];
if (el.tagName === 'BUTTON'
&& el.getAttribute('aria-haspopup') === 'menu') {
return i;
}
}
return null;
}
""")
핵심 차이:
❌ document.querySelectorAll('button') → 페이지 전체 버튼 (모달 포함)
✅ avatar.parentElement.children → 헤더 영역 버튼만 (모달 무관)
모달이 몇 개가 뜨든 헤더 영역은 변하지 않는다
2. 모달 사전 닫기
# 언어 전환 시도 전에 모달을 먼저 닫는다
self.dismiss_free_trial_modal_if_present()
self.dismiss_feature_release_modal()
모달이 overlay 로 남아있으면 force=True 가 없으면 선택이 안 된다
3. 재시도 로직 + 구체적 예외
max_retries = 2
for attempt in range(max_retries):
try:
# 언어 버튼 선택 → Korean 드롭다운 선택
lang_btn.click(force=True, timeout=5000)
korean_btn = page.get_by_role("button", name="Korean")
korean_btn.wait_for(state="visible", timeout=3000)
korean_btn.click()
return # 성공하면 즉시 종료
except PlaywrightTimeoutError:
if attempt < max_retries - 1:
# 드롭다운이 안 열렸을 수 있으므로 Escape 후 재시도
page.keyboard.press("Escape")
❌ 기존: except Exception → 무슨 에러인지 알 수 없음
✅ 수정: except PlaywrightTimeoutError → Timeout 만 재시도, 나머지는 바로 실패
일반화: 인덱스 기반 로케이터가 위험한 이유
nth(0), nth(1), .first → 페이지 구조가 변하면 밀린다
언제 변하나?
| 상황 | 원인 |
|---|---|
| 모달 팝업 | 새 버튼이 DOM 에 추가됨 |
| A/B 테스트 | 같은 페이지인데 구조가 다름 |
| 권한별 UI | 관리자는 버튼 3개, 일반은 2개 |
| 언어/지역 | 번역에 따라 요소 순서 변경 |
대안: 인덱스 대신 구조적 관계로 찾기
# ❌ 위험 — 글로벌 인덱스
page.locator("button").nth(3)
# ✅ 안전 — 부모 컨테이너 기반
avatar = page.locator("button:has(p)").first
avatar_parent = avatar.locator("..")
lang_btn = avatar_parent.locator("[aria-haspopup='menu']")
부모를 먼저 찾고, 그 안에서 속성으로 찾는다
이렇게 하면 모달이 10개 떠도 영향이 없다
후속 조치: 영어 폴백
언어 전환이 실패하면 이후 한국어 텍스트 기반 로케이터가 전부 Timeout 나므로, 영어 폴백을 보험으로 추가했다
try:
tab = self.page.get_by_role("tab", name="공유 Bench")
tab.click()
except PlaywrightTimeoutError:
tab = self.page.get_by_role("tab", name="Shared Bench")
tab.click()
폴백이 동작하면 warning 로그를 남긴다 — 정상 경로가 아니라는 신호
사용법
# 언어 전환 관련 시나리오 실행
pytest tests/step_definitions/ -k "login" --headed
주의점
force=True는 overlay 가 남아있을 때만 쓴다 — 남용하면 보이지 않는 요소를 선택하게 된다except Exception을 쓰면 재시도해야 할 에러와 즉시 실패해야 할 에러를 구분할 수 없다
반응형
'TIL > Claude Code' 카테고리의 다른 글
| [TIL][Claude Code] Playwright MCP 로 E2E 디버깅하기 — browser_snapshot 워크플로우 (2) | 2026.04.02 |
|---|---|
| [TIL][Claude Code] "이게 최선이야?" 를 자동으로 묻게 만들기 (0) | 2026.03.30 |
| [TIL][Claude Code] CLAUDE.md 가 500줄이 된 이유 (0) | 2026.03.24 |
| [TIL][Claude Code] Claude Code 초기 세팅 #3 - 숨겨진 단축키와 기능 (0) | 2026.02.18 |
| [TIL][Claude Code] Claude Code 초기 세팅 #2 - MCP, Hooks, Skills 로 워크플로우 자동화 (0) | 2026.02.17 |