TIL/Claude Code

[TIL][Playwright] 모달이 버튼 인덱스를 밀어낸다 — nth() 로케이터의 함정

아람2 2026. 4. 4. 21:00
반응형

언어 전환 버튼을 선택하는 코드가 있다

로컬에서는 잘 돌았는데, 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 을 쓰면 재시도해야 할 에러와 즉시 실패해야 할 에러를 구분할 수 없다
반응형