왜 Selenium 테스트가 불안정한가 (그리고 영원히 해결하는 방법)
Source: Dev.to
이 기사에서 다루는 내용
- 불안정성 문제 –
time.sleep()와WebDriverWait만으로는 충분하지 않은 이유 - 불안정한 테스트의 원인 – UI 상태 변화와의 경쟁
- 안정성 솔루션 – DOM, 네트워크, 애니메이션, 레이아웃 변화를 모니터링
- 한 줄 통합 –
stabilize()로 드라이버를 감싸기 — 테스트 수정 없이 - 전체 진단 – 테스트가 차단된 정확한 이유 파악
Selenium을 일주일 이상 사용해 본 적이 있다면, 아마 다음과 같은 코드를 작성했을 것입니다:
driver.get("https://myapp.com/dashboard")
time.sleep(2) # Wait for page to load
driver.find_element(By.ID, "submit-btn").click()
time.sleep(1) # Wait for AJAX
잘못된 코드라는 걸 알면서도 “동작한다”는 안도감을 느낄 수 있습니다. 하지만 어느 순간 그 안도감은 사라집니다. CI 서버가 로컬 머신보다 10 % 느려지고, 갑자기 테스트가 20 % 정도 실패하기 시작합니다.
이것이 불안정한 테스트의 이야기이며, 왜 발생하는지, 그리고 waitless라는 라이브러리가 어떻게 이를 영원히 없앨 수 있는지 설명합니다.
불안정성 문제
실제 시나리오를 생각해 보세요: 사용자가 버튼을 클릭하고, API 호출이 이루어지고, 데이터가 반환되고, React가 다시 렌더링하고, 스피너가 사라지고, 테이블이 나타나는 React 대시보드. 전체 흐름은 약 400 ms가 걸리지만, 테스트는 다음과 같이 작성됩니다:
button = driver.find_element(By.ID, "load-data")
button.click()
table = driver.find_element(By.ID, "data-table") # 💥 BOOM
테이블이 아직 존재하지 않기 때문에 Selenium은 NoSuchElementException을 발생시킵니다. 흔히 쓰이는 빠른 “수정”은 다음과 같습니다:
button.click()
time.sleep(2)
table = driver.find_element(By.ID, "data-table") # Works… usually
time.sleep()의 문제
- 불필요한 지연을 추가함 (예: 필요보다 2초 느림)
- API가 예상보다 오래 걸릴 때 여전히 불안정함
- 실패가 발생했을 때 원인 파악이 어려움
전통적인 해결책이 작동하지 않는 이유
time.sleep() — 순진한 접근법
고정된 시간만큼 대기하고 UI가 준비되길 기대합니다.
문제점:
- 너무 짧음 → 테스트 실패
- 너무 김 → 테스트 스위트가 느려짐
- 실제 상태에 대한 피드백 없음
WebDriverWait — “올바른” 접근법
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "submit-btn"))
)
특정 조건을 기다리지만 하나의 요소만 확인합니다. 다음을 무시합니다:
- 진행 중인 애니메이션으로 인한 오버레이
- 대기 중인 AJAX 요청
- 요소를 이동시키는 React 재렌더링
재시도 데코레이터 — 부정적인 접근법
@retry(tries=3, delay=1)
def test_dashboard():
driver.find_element(By.ID, "submit-btn").click()
재시도는 불안정성을 숨길 뿐, 해결하지는 못합니다.
실제로 불안정한 테스트를 일으키는 원인은?
수백 개의 불안정한 테스트를 디버깅한 결과, 근본 원인은 UI와의 경쟁입니다:
| 수행한 작업 | 실제 일어나는 일 |
|---|---|
| 버튼 클릭 | 프레임워크에 의해 DOM이 변형 중 |
| 텍스트 내용 검증 | AJAX 응답이 아직 진행 중 |
| 모달과 상호작용 | CSS 전환이 아직 애니메이션 중 |
| 네비게이션 링크 클릭 | 레이아웃 이동으로 요소가 움직임 |
진짜 질문은 “이 요소가 클릭 가능한가?”가 아니라 “전체 페이지가 안정적이고 상호작용할 준비가 되었는가?” 입니다.
“안정성” 정의
안정적인 UI를 나타내는 네 가지 핵심 신호가 있습니다:
-
DOM 안정성 – 요소가 추가, 제거, 수정되지 않음.
감지:MutationObserver로 문서 루트를 감시하고, 마지막 변형 이후 경과 시간을 추적합니다. -
네트워크 유휴 – 모든 AJAX 요청이 완료됨.
감지:fetch()와XMLHttpRequest를 가로채고, 대기 중인 요청 수를 셉니다. -
애니메이션 완료 – 모든 CSS 애니메이션 및 전환이 끝남.
감지:animationstart,animationend,transitionstart,transitionend이벤트를 청취합니다. -
레이아웃 안정성 – 요소 이동이 멈추고 레이아웃 변동이 없음.
감지: 인터랙티브 요소들의 바운딩 박스 위치를 시간에 따라 추적합니다.
아키텍처
JavaScript 계측 (브라우저에서 실행)
window.__waitless__ = {
pendingRequests: 0,
lastMutationTime: Date.now(),
activeAnimations: 0,
isStable() {
if (this.pendingRequests > 0) return false;
if (Date.now() - this.lastMutationTime < 100) return false;
// Add additional checks for animations and layout if needed
return true;
}
};
스크립트는 execute_script()를 통해 주입되며 DOM 변형, 네트워크 활동, 애니메이션을 모니터링합니다.
Python 엔진 (안정성 평가)
class StabilizationEngine:
def wait_for_stability(self):
"""Wait until all stability signals are satisfied."""
# Checks performed automatically:
# ✓ DOM mutations have settled
# ✓ Network requests completed
# ✓ Animations finished
# ✓ Layout is stable
엔진은 isStable()이 True를 반환할 때까지 브라우저 상태를 반복적으로 조회합니다.
마법: 한 줄 통합
테스트를 수정할 필요가 전혀 없습니다. 드라이버를 감싸는 한 줄만 추가하면 됩니다:
from waitless import stabilize
driver = webdriver.Chrome()
driver = stabilize(driver) # ← Only change needed
# Existing tests work unchanged
driver.find_element(By.ID, "button").click() # Auto‑waits!
stabilize()는 find_element() 호출을 가로채는 StabilizedWebDriver를 반환합니다. 반환된 요소들은 StabilizedWebElement로 감싸지며, 그 click() 메서드는 먼저 안정성을 기다린 뒤 클릭을 수행합니다:
class StabilizedWebElement:
def click(self):
self._engine.wait_for_stability() # Auto‑wait!
return self._element.click() # Then click
이제 테스트는 기다리고 있다는 사실을 인식하지 못하고, 단순히 실패하지 않을 뿐입니다.
엣지 케이스 처리
실제 애플리케이션은 스피너, 분석 폴링, WebSocket 하트비트 등 지속적인 활동을 가집니다. waitless는 구성 가능한 임계값을 제공합니다.
예시: 무한 애니메이션 무시
from waitless import StabilizationConfig, stabilize
config = StabilizationConfig(
network_idle_threshold=2, # Allow up to 2 pending requests
animation_detection=False, # Ignore spinners/continuous animations
strictness='relaxed' # Only check DOM mutations
)
driver = stabilize(driver, config=config)
테스트를 다시 작성하지 않고도 앱의 동작에 맞게 감지를 맞춤 설정할 수 있습니다.