매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것 시작은 HR 전화 "J님, 신입 한 명 들어와요. 자동화 가르쳐주세요." 3년 매뉴얼 QA 경력자. 이름은 민지. 28살. 나는 고민했다. 뭘 먼저 가르치지. Python? HTML? Git? 결론은 "일단 Selenium 돌려보자"였다. 첫날, 내 자동화 프레임워크 보여줬다. 민지 표정이 굳었다. "선배, 이게 다 뭐예요?" Page Object Model. Config 파일. Fixture. Decorator. 설명하는데 1시간. 민지는 계속 끄덕였다. 하지만 눈이 멍했다. 그때 깨달았다. 내 코드가 생각보다 복잡하다. 첫 번째 질문: "왜 find_element 안 써요?" 민지 첫 과제. 로그인 테스트 스크립트 짜기. Selenium 공식 문서 보고 짰다. 코드 리뷰 요청 왔다. driver.find_element(By.ID, "username").send_keys("test") driver.find_element(By.ID, "password").send_keys("1234") driver.find_element(By.XPATH, "//button[@type='submit']").click()내가 짠 코드는 이랬다. self.login_page.enter_username("test") self.login_page.enter_password("1234") self.login_page.click_login_button()민지가 물었다. "선배 코드엔 find_element가 없는데요?" 나는 설명했다. Page Object Model. Locator 추상화. 유지보수성. 민지는 또 끄덕였다. 근데 다음 날 코드는 여전히 find_element 투성이. 화가 나려다 멈췄다. 민지가 이해 못 한 게 아니다. 내가 "왜"를 안 알려줬다.엘리먼트가 안 잡힐 때 민지 두 번째 과제. 검색 기능 테스트. 2시간 뒤 민지가 왔다. "선배, 이거 계속 에러나요." NoSuchElementException 나는 물었다. "wait 넣었어?" "wait요?" WebDriverWait. Explicit Wait. Implicit Wait. 설명했다. 민지는 코드에 time.sleep(3) 박았다. "아니 그게 아니라..." 나는 다시 설명했다. sleep은 나쁜 습관. 테스트 느려짐. Flaky 테스트 원인. 민지가 물었다. "그럼 왜 선배 코드엔 wait이 안 보여요?" 내 Base Page 클래스 열어봤다. 모든 메소드에 wait 내장. def _wait_and_find(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) )민지는 내 코드만 봤으니 wait을 몰랐다. 나는 당연하다고 생각한 것들. 민지에겐 보이지 않았다. 프레임워크 안에 숨어있었으니까. 세 번째 질문: "이건 왜 깨져요?" 민지 자동화 스크립트 10개 짰다. CI에 올렸다. 다음 날 아침. Jenkins 빨간불. 민지 테스트 5개 실패. 민지가 당황했다. "제 컴퓨터에선 됐는데요?" Headless 모드. 크롬 버전. 타임아웃 설정. 환경변수. 나는 하나씩 체크했다. 민지는 옆에서 봤다. "선배, 이런 거 어떻게 다 알아요?" "다 겪어봐서." 실패 원인은 타임아웃이었다. CI 서버가 느렸다. 민지 코드는 타임아웃 3초 하드코딩. 내 코드는 환경변수로 관리. TIMEOUT = os.getenv('TEST_TIMEOUT', 10)민지가 물었다. "왜 이렇게 해요?" "CI는 로컬보다 느리거든." "그럼 전부 이렇게 해야 돼요?" "응." 민지 표정이 어두워졌다. "자동화 어렵네요." 나도 그랬다고 말해줬다. 4년 전 나도 헤맸다고.내 코드 다시 보기 민지 질문이 계속됐다. "왜 fixture를 이렇게 써요?" "conftest.py는 뭐예요?" "이 decorator는 왜 만든 거예요?" 질문마다 내 코드 다시 봤다. 4년간 쌓인 코드. 나한테는 당연했다. 근데 민지 눈으로 보니 복잡했다. 주석 없는 함수. 이름만으로는 모호한 변수. 왜 이렇게 짰는지 기억 안 나는 로직. 민지는 내 코드 리뷰어가 됐다. "선배, 이 함수 이름이 뭘 하는 건지 모르겠어요." def _handle_alert(self): ...민지 말이 맞았다. alert 뜨면 accept? dismiss? 코드 봐야 안다. 리팩토링했다. def accept_alert_if_present(self): """Alert이 있으면 accept, 없으면 무시""" ...민지가 물었다. "이 try-except는 왜 있어요?" try: element.click() except ElementClickInterceptedException: self.driver.execute_script("arguments[0].click();", element)"어... 가끔 엘리먼트가 다른 거에 가려져서." "그럼 주석 달면 안 돼요?" 또 맞았다. 주석 추가했다. 민지 덕분에 내 코드가 나아졌다. 온보딩 방법 바꾸기 3주 뒤. 민지는 여전히 헤맸다. 내 접근이 틀렸다. "일단 프레임워크 써봐" 방식. 민지는 프레임워크 구조를 이해 못 했다. 왜 이렇게 짜야 하는지. 방법을 바꿨다. 온보딩 문서 만들기. 1단계: 날것의 Selenium 가장 기본부터. driver 띄우고 find_element. from selenium import webdriverdriver = webdriver.Chrome() driver.get("https://example.com") driver.find_element(By.ID, "username").send_keys("test")"이게 자동화의 시작이야. 이것만 알아도 테스트 짤 수 있어." 민지가 직접 짰다. 로그인. 검색. 장바구니. 코드는 지저분했다. 반복도 많았다. 하지만 돌아갔다. 민지 표정이 밝아졌다. "선배, 이거 재밌어요!" 2단계: 반복의 고통 민지한테 과제 줬다. "로그인 테스트 10개 짜봐." 다음 날 민지가 왔다. "선배, 코드가 너무 길어요." find_element 코드 100번 반복. Copy-paste 지옥. "개발자가 ID 바꾸면?" "다... 다 고쳐야죠." "그래서 함수로 빼는 거야." 민지는 함수를 만들었다. def login(driver, username, password): driver.find_element(By.ID, "username").send_keys(username) driver.find_element(By.ID, "password").send_keys(password) driver.find_element(By.ID, "login-btn").click()"훨씬 낫다. 이게 추상화의 시작이야."3단계: Page Object Model 민지 함수가 늘어났다. 50개. "선배, 이거 어떻게 관리해요?" "Page Object Model." 페이지별로 클래스 만들기. 로케이터 분리. 메소드로 액션 정의. class LoginPage: def __init__(self, driver): self.driver = driver self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") def login(self, username, password): self.driver.find_element(*self.username_input).send_keys(username) ...이제 민지가 내 코드를 이해했다. "선배, 선배 코드가 이렇게 된 거였구나!" "응. 고통 받다보면 자연스럽게 이렇게 돼." 4단계: 프레임워크 pytest. fixture. conftest.py. Base Page. 민지는 이제 질문이 구체적이었다. "왜 fixture를 session scope로 해요?" "Base Page에 wait을 넣는 게 좋은 이유가 뭐예요?" 3주 전 질문과 달랐다. 문맥을 이해했다. 민지는 직접 Base Page 만들었다. 내 것과 비슷했다. "선배, 제가 짠 거 리뷰해주세요." 코드 봤다. 생각보다 괜찮았다. "민지야, 너 이제 자동화 엔지니어 같은데?" 민지가 웃었다. "아직 멀었어요." 민지의 역습 6주 뒤. 민지가 PR 날렸다. "선배, 이거 개선했어요." 내 retry 로직. 민지가 리팩토링했다. 원래 코드: def click_with_retry(self, locator, max_attempts=3): for i in range(max_attempts): try: element = self._wait_and_find(locator) element.click() return except: if i == max_attempts - 1: raise time.sleep(1)민지 코드: def click_with_retry(self, locator, max_attempts=3, retry_delay=1): """엘리먼트 클릭을 재시도. Stale element 대응.""" for attempt in range(max_attempts): try: element = self._wait_and_find(locator) element.click() logger.info(f"Click succeeded on attempt {attempt + 1}") return except (StaleElementReferenceException, ElementClickInterceptedException) as e: if attempt == max_attempts - 1: logger.error(f"Click failed after {max_attempts} attempts") raise logger.warning(f"Click failed, retrying... ({attempt + 1}/{max_attempts})") time.sleep(retry_delay)로깅 추가. Exception 구체화. Docstring. 내 코드보다 나았다. "Merge 할게." 민지가 좋아했다. "제 PR 첫 머지예요!" 깨달은 것들 민지 가르치면서 배웠다. 1. 내 코드는 내가 생각한 것보다 복잡하다 4년간 쌓인 코드. 당연하다고 생각한 패턴들. 초보자 눈엔 복잡한 미로. 2. "왜"를 알려줘야 한다 "이렇게 해" 방식은 안 먹힌다. 왜 Page Object Model? 왜 fixture? 왜 wait? 고통을 먼저 겪게 하고, 해결책을 제시하기. 3. 단계적 학습이 중요하다 처음부터 프레임워크 보여주기 = 실패 날것 Selenium → 반복의 고통 → 추상화 → 프레임워크 순서가 있다. 4. 질문은 코드 리뷰다 민지 질문은 내 코드의 문제점이었다. 주석 없는 함수. 모호한 네이밍. 불필요한 복잡도. 민지 덕분에 리팩토링했다. 5. 가르치면서 성장한다 민지한테 설명하려니 내가 제대로 이해 못 한 게 보였다. "이거 왜 이렇게 짰지?" 기억 안 나는 코드들. 다시 공부했다. Python decorator. Pytest fixture scope. Selenium wait 전략. 민지 때문에 내가 나아졌다. 지금 민지는 민지는 이제 자동화 스크립트 30개 관리한다. CI 빨간불 나면 혼자 고친다. PR 리뷰도 한다. 어제 민지가 말했다. "선배, 저도 후배 가르칠 수 있을 것 같아요." "그래? 뭘 먼저 가르칠 건데?" "당연히 날것 Selenium이죠. 고통부터 겪게 해야죠." 민지가 웃었다. 나도 웃었다. 민지가 진짜 이해했다. 다음 신입한테 다음 달 신입 또 온다. 이번엔 개발자 출신. 준비는 됐다. 온보딩 문서 있다. 민지한테 멘티 맡길 수도 있다. 근데 또 다를 거다. 개발자는 질문이 다를 테니까. "왜 unittest 안 쓰고 pytest 써요?" "이 구조는 왜 이렇게 짰어요?" 또 내 코드 다시 볼 거다. 또 리팩토링할 거다. 가르치는 게 배우는 거다. 민지가 증명했다.민지 질문이 내 코드를 고쳤다. 다음 신입도 기대된다.

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가 새벽 3시, 슬랙 알림 새벽 3시. 슬랙 알림음에 눈이 떠졌다. "CI Build Failed - 17 tests failed" 저녁에 분명 다 통과했던 테스트들이었다. 아침 출근해서 다시 돌렸다. 전부 통과. 이게 벌써 이번 주에 세 번째다. 팀 슬랙에 개발자가 물었다. "J님, 테스트 불안정한 것 같은데 확인 가능하세요?" 확인 가능하다. 불안정한 거 맞다. 그게 문제다. Flaky 테스트. QA 자동화의 숙적. 같은 코드, 같은 환경인데 결과가 다르다. 오늘은 통과, 내일은 실패. 아침엔 성공, 저녁엔 실패. 이거 해결하려고 지난 한 달을 썼다.Flaky 테스트가 뭔데 Flaky 테스트는 비결정적 테스트다. 동일한 조건인데 결과가 달라진다. 예를 들면 이렇다. def test_user_login(): driver.get("https://example.com/login") driver.find_element(By.ID, "username").send_keys("test@test.com") driver.find_element(By.ID, "password").send_keys("password123") driver.find_element(By.ID, "login-btn").click() # 여기서 가끔 실패함 assert driver.find_element(By.ID, "dashboard").is_displayed()10번 중 7번은 통과한다. 3번은 실패한다. 실패 이유가 매번 다르다."Element not found" "Timeout" "Stale element reference"코드는 안 바뀌었다. 테스트 대상도 안 바뀌었다. 그런데 결과가 다르다. 우리 팀 자동화 테스트 1,247개. 이중 Flaky 테스트가 43개였다. 3.4%다. 적어 보인다? 절대 아니다. 43개가 랜덤하게 실패하면, 한 번 돌릴 때마다 5~10개씩 빨간불. 개발자들이 CI 결과를 신뢰 안 한다. "아 그거 또 Flaky 테스트 아니에요?" 자동화의 신뢰도가 무너진다. 원인 1: 타이밍 이슈 가장 흔한 원인. 타이밍. 웹 페이지는 로딩 시간이 들쭉날쭉하다. 네트워크 상태, 서버 부하, 브라우저 렌더링. 테스트는 기다려주지 않는다. # 문제 있는 코드 driver.find_element(By.ID, "submit-btn").click() assert driver.find_element(By.ID, "success-message").is_displayed()클릭하고 바로 체크한다. 성공 메시지가 나타나는 데 0.5초 걸린다면? 실패한다. 내가 처음 작성한 테스트 중 30%가 이랬다. 로컬에선 통과. CI에선 실패. 로컬은 내 맥북 프로 M1이다. CI 서버는 2코어 가상머신이다. 속도가 다르다. 당연히 타이밍이 안 맞는다. 해결책은 명시적 대기. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECdriver.find_element(By.ID, "submit-btn").click()# 최대 10초 대기 wait = WebDriverWait(driver, 10) element = wait.until( EC.visibility_of_element_located((By.ID, "success-message")) ) assert element.is_displayed()명시적으로 조건을 기다린다. "이 요소가 보일 때까지" "이 텍스트가 나타날 때까지" 모든 테스트에 적용했다. Flaky 비율이 43개에서 28개로 줄었다. 하지만 충분하지 않았다.원인 2: 상태 관리 문제 테스트들이 서로 영향을 준다. 테스트 A가 데이터를 만든다. 테스트 B가 그 데이터를 쓴다. 테스트 C가 그 데이터를 지운다. 순서대로 실행되면 문제없다. Pytest는 병렬 실행한다. C → A → B 순서로 돌면? B가 실패한다. 데이터가 없으니까. 실제 있었던 케이스다. # test_user_creation.py def test_create_user(): user = create_user(email="test@test.com") assert user.id == 12345# test_user_login.py def test_login_existing_user(): # test_create_user가 먼저 돌았다고 가정 response = login(email="test@test.com") assert response.status_code == 200테스트 간 의존성이 있다. 독립적이지 않다. 첫 실행엔 순서가 맞아서 통과. 두 번째 실행엔 순서가 바뀌어서 실패. 해결은 격리다. @pytest.fixture(scope="function") def test_user(): # 각 테스트마다 새 유저 생성 user = create_user(email=f"test_{uuid4()}@test.com") yield user # 테스트 끝나면 삭제 delete_user(user.id)def test_login_existing_user(test_user): response = login(email=test_user.email) assert response.status_code == 200각 테스트가 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 독립적으로 돌아간다. 이것도 적용했다. 28개에서 19개로 줄었다. 하지만 아직 19개가 남았다. 원인 3: 네트워크 불안정성 외부 API 호출하는 테스트들. 우리 서비스는 결제 API를 쓴다. PG사 테스트 서버에 요청 보낸다. def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"이 테스트는 외부 의존성이 있다. PG사 테스트 서버 상태에 달렸다. 서버 점검: 실패 네트워크 지연: 타임아웃 레이트 리밋: 429 에러 우리 코드는 멀쩡한데 실패한다. 처음엔 재시도 로직을 넣었다. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2)) def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"3번까지 재시도한다. 그래도 가끔 실패한다. 근본적 해결책은 모킹이다. @patch('payment_gateway.charge') def test_payment_processing(mock_charge): mock_charge.return_value = PaymentResponse(status="success") response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"외부 API를 가짜로 대체한다. 항상 예측 가능한 응답을 준다. "그럼 진짜 API 연동은 안 테스트해요?" E2E 테스트 몇 개만 실제 API 쓴다. 나머지는 모킹한다. 통합 테스트와 E2E를 분리했다. 통합 테스트는 빠르고 안정적이다. E2E는 느리지만 실제 환경이다. 19개에서 11개로 줄었다.원인 4: 동시성 문제 가장 디버깅하기 어려운 케이스. 우리 앱은 웹소켓으로 실시간 알림을 보낸다. 테스트에서 알림이 오는지 체크한다. def test_notification_received(): send_notification(user_id=123, message="Test") # 알림이 올 때까지 대기 notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1100번 중 95번은 통과한다. 5번은 알림이 안 온다. 왜일까? 웹소켓 연결 타이밍 문제였다.테스트가 시작된다 웹소켓 연결을 시작한다 알림을 보낸다 웹소켓이 아직 연결 안 됐다 알림을 못 받는다순서가 꼬인다. 레이스 컨디션이다. 해결책은 연결 확인. def test_notification_received(): # 웹소켓 연결 대기 wait_for_websocket_connection(user_id=123) # 연결 확인 후 알림 전송 send_notification(user_id=123, message="Test") notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1연결이 완료된 걸 확인하고 진행한다. 비슷한 문제가 또 있었다. 데이터베이스 트랜잭션이다. def test_user_update(): update_user(user_id=123, name="New Name") # 바로 조회 user = get_user(user_id=123) assert user.name == "New Name"가끔 실패한다. 이전 이름이 나온다. 왜? 트랜잭션 커밋이 비동기다. 업데이트 요청하고 바로 읽으면, 아직 커밋 안 된 상태를 읽는다. def test_user_update(): update_user(user_id=123, name="New Name") # 커밋 대기 wait_for_transaction_commit() user = get_user(user_id=123) assert user.name == "New Name"명시적으로 커밋을 기다린다. 11개에서 6개로 줄었다. 원인 5: 테스트 데이터 충돌 여러 테스트가 같은 데이터를 쓴다. def test_user_search(): results = search_users(query="test@test.com") assert len(results) == 1def test_user_creation(): user = create_user(email="test@test.com") assert user.email == "test@test.com"첫 번째 테스트는 1개를 기대한다. 두 번째 테스트가 먼저 돌면? 같은 이메일로 유저가 생긴다. 첫 번째 테스트가 실패한다. 2개가 검색되니까. 병렬 실행하면 더 복잡하다. A와 B가 동시에 같은 이메일로 유저 생성. 둘 다 실패할 수 있다. 중복 키 에러. 해결책은 고유한 데이터. def test_user_search(): unique_email = f"test_{uuid4()}@test.com" create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1def test_user_creation(): unique_email = f"test_{uuid4()}@test.com" user = create_user(email=unique_email) assert user.email == unique_emailUUID로 유니크한 값을 만든다. 충돌이 없다. 픽스처로 패턴화했다. @pytest.fixture def unique_email(): return f"test_{uuid4()}@test.com"def test_user_search(unique_email): create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1모든 테스트에 적용했다. 6개에서 3개로 줄었다. 마지막 3개 3개가 남았다. 하나는 브라우저 캐시 문제였다. 이전 테스트의 쿠키가 남아있어서, 로그아웃 테스트가 실패했다. 해결: 각 테스트마다 브라우저 재시작. @pytest.fixture(scope="function") def driver(): driver = webdriver.Chrome() yield driver driver.quit()하나는 날짜/시간 의존 테스트. def test_daily_report(): report = generate_daily_report(date=today()) assert report.date == today()자정을 넘기면 실패한다. 테스트 시작할 때는 11시 59분. 체크할 때는 0시 1분. 해결: 고정된 날짜 사용. def test_daily_report(): test_date = datetime(2024, 1, 15) report = generate_daily_report(date=test_date) assert report.date == test_date마지막 하나는 메모리 누수. 테스트가 100개씩 돌면, 70번째쯤에서 브라우저가 느려진다. 80번째쯤 타임아웃. 해결: 주기적 재시작. test_count = 0@pytest.fixture(scope="function") def driver(): global test_count test_count += 1 if test_count % 50 == 0: # 50개마다 브라우저 재시작 restart_browser() driver = webdriver.Chrome() yield driver driver.quit()3개를 다 잡았다. Flaky 테스트 0개. 지금 우리 시스템 Flaky 테스트를 추적하는 대시보드를 만들었다. class FlakyTracker: def __init__(self): self.test_history = {} def record_result(self, test_name, passed): if test_name not in self.test_history: self.test_history[test_name] = [] self.test_history[test_name].append(passed) # 최근 100번 실행 중 성공률 recent = self.test_history[test_name][-100:] success_rate = sum(recent) / len(recent) if success_rate < 0.95: alert_flaky_test(test_name, success_rate)성공률이 95% 미만이면 알림. 자동으로 슬랙에 리포트. "test_user_login의 성공률이 92%입니다." 즉시 확인하고 고친다. CI 설정도 바꿨다. # .github/workflows/test.yml - name: Run Tests run: pytest --reruns 2 --reruns-delay 1실패한 테스트는 자동으로 한 번 더 돌린다. 진짜 버그면 두 번 다 실패한다. Flaky면 두 번째는 통과한다. 완벽한 해결책은 아니다. 하지만 실용적이다. 테스트 실행 시간도 최적화했다.빠른 테스트: 단위 테스트, 모킹 사용 중간 테스트: 통합 테스트, 일부 모킹 느린 테스트: E2E, 실제 환경빠른 테스트는 PR마다. 중간 테스트는 머지 전. 느린 테스트는 배포 전. 각각 역할이 다르다. 결과 Flaky 테스트 비율: 3.4% → 0.1% CI 신뢰도가 올라갔다. 개발자들이 테스트 결과를 믿는다. "CI 실패했는데 확인 부탁드려요." 이제 이 메시지가 오면, 진짜 버그다. 배포 전 불안감이 줄었다. 테스트가 통과하면 배포한다. 의심 안 한다. 자동화 테스트의 가치가 올라갔다. 하지만 유지보수 시간은 늘었다. 명시적 대기, 픽스처, 모킹. 코드가 복잡해졌다. 테스트 하나 작성하는 시간:전: 15분 후: 30분두 배가 됐다. 하지만 디버깅 시간은 줄었다.전: 주당 10시간 후: 주당 2시간트레이드오프다. 초반에 시간 더 쓰고, 나중에 시간을 아낀다. 교훈 Flaky 테스트는 기술 부채다. 쌓이면 감당 못 한다. 처음엔 몇 개 괜찮아 보인다. "이거 가끔 실패하는데 뭐 괜찮겠지." 아니다. 절대 괜찮지 않다. 하나가 두 개 되고, 두 개가 열 개 된다. 어느 순간 자동화를 신뢰 못 한다. 자동화의 의미가 없어진다. 발견 즉시 고쳐야 한다. '나중에'는 없다. 두 번째. 근본 원인을 찾아야 한다. 재시도 로직으로 때우지 마라. # 이렇게 하지 마라 @retry(stop=stop_after_attempt(5)) def test_something(): # flaky test증상만 감춘다. 문제는 남아있다. 왜 실패하는지 알아야 한다. 타이밍인지, 상태인지, 네트워크인지. 세 번째. 테스트는 독립적이어야 한다. 서로 영향 주면 안 된다. 각 테스트는 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 돌아간다. 네 번째. 외부 의존성을 최소화한다. 모킹 가능한 건 모킹한다. E2E는 최소한으로. 다섯 번째. 모니터링한다. 어떤 테스트가 불안정한지 추적한다. 성공률을 기록한다. 데이터로 관리한다. 여전히 어려운 것들 완벽한 해결책은 없다. 새 기능 테스트를 작성할 때, Flaky 될지 안 될지 예측 못 한다. 돌려봐야 안다. 프론트엔드 E2E는 특히 어렵다. 브라우저, 렌더링, 애니메이션. 변수가 너무 많다. 모바일 앱 테스트는 더 심하다. 디바이스마다 다르다. OS 버전마다 다르다. 완전히 안정적인 자동화는, 아직 먼 이야기다. 하지만 계속 나아진다. 3.4%에서 0.1%로 왔다. 다음 목표는 0%다. 불가능해 보인다. 시도는 해볼 거다.새벽 3시 알림은 이제 안 온다. CI가 안정적이니까. 그래도 가끔 확인한다. 혹시 모르니까. Flaky와의 전쟁은 계속된다.

밤새 돌린 테스트가 새벽 3시에 깨졌을 때

밤새 돌린 테스트가 새벽 3시에 깨졌을 때

새벽 3시 알람, 그리고 깨진 파이프라인 어제 저녁 8시에 큰 기능 PR을 머지했다. 전체 테스트 스위트를 도는 건 보통 4시간 걸린다. 자동화 엔지니어라면 알 거다. 밤샘 CI는 숙명이다. 침대에 누웠는데 핸드폰이 울렸다. Jenkins 알람이다. 새벽 3시 정각. 빌드는 FAILED. 화면을 봤을 때 느낌이 왔다. 이건 단순한 실패가 아니다. 뭔가 깊은 거다. 일어나 앉았다. 잠은 포기했다. 아무도 지금 연락할 수 없다. 개발팀은 자고 있고, 나만 깨어 있다. 자동화의 밤은 혼자 버티는 거다.노트북을 켰다. 모니터 세 개를 돌렸다. 한쪽에는 Jenkins 로그, 한쪽에는 테스트 리포트, 한쪽에는 IDE를 띄웠다. 새벽 3시의 나는 이미 자동화 엔지니어가 아니라 범죄 수사관이다. 파이프라인 사건의 현장 FAILED: test_checkout_with_coupon FAILED: test_user_profile_update FAILED: test_notification_badge_count ERROR: Connection timeout at step_wait_for_element보자. 셀레늄 테스트 세 개가 떴다. 패턴이 있다. 모두 사용자 액션이 들어가는 테스트들이다. 그리고 마지막 줄. Connection timeout. 이게 핵심이다. CI 환경을 봤다. 새벽에도 파이프라인은 도는데, 리소스 사용량이 이상했다. 다른 배치 작업이랑 겹쳤나보다. 메모리 70%, CPU 50%. 정상은 아니다. 여기서 대부분의 자동화 엔지니어가 실수한다. "아, 환경 문제구나" 하고 재실행한다. 그리고 다음날 또 깨진다. 반복한다. 나는 다르게 생각했다. 환경 문제가 맞으면, 이 테스트는 프로덕션에서도 깨질 가능성이 높다. 즉 내 테스트가 너무 민감하다는 뜻이다.그래서 코드를 뜯어봤다. wait_for_element(locator, timeout=10) 10초다. 10초 wait는 신뢰할 수 없다. CI 환경이 느릴 땐 평정심이 필요하다. 내가 쓴 코드를 봤다. def wait_for_element(self, locator, timeout=10): try: WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) except TimeoutException: raise AssertionError(f"Element not found: {locator}")이 코드의 문제는 뭔가? Retry 로직이 없다. 한 번 깨지면 끝이다. Flaky 테스트의 정의다. 새벽 3시, 나는 이 코드를 고쳤다. def wait_for_element(self, locator, timeout=10, retries=3): for attempt in range(retries): try: WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) return True except TimeoutException: if attempt == retries - 1: raise AssertionError(f"Element not found after {retries} attempts: {locator}") time.sleep(2) # 2초 대기 후 재시도재시도 로직을 넣었다. 3번까지 시도한다. 2초 간격으로. 그 다음은 뭔가? 테스트 순서다. 새벽 파이프라인에선 병렬 실행도 있다. 리소스가 줄어드니까 어떤 테스트가 먼저 끝날지 모른다. 그럼 상태 관리가 문제가 된다. @pytest.fixture(autouse=True) def setup_and_teardown(): # 각 테스트마다 클린 상태에서 시작 driver.delete_all_cookies() driver.execute_script("window.sessionStorage.clear();") yield # 테스트 후 정리 if driver: driver.quit()이건 이미 있었다. 좋다. 그럼 다음은? 왜 3시에 깨졌는가 파이프라인을 분석했다. 같은 시간대에 돌고 있던 게 뭐였나?01:00 - 앱 빌드 (30분) 01:30 - 데이터베이스 마이그레이션 테스트 (90분) 02:10 - 내 자동화 테스트 (4시간)아. DB 마이그레이션이 끝나는 게 2시간 40분 즈음이다. 거기서 뭔가 리소스를 남기고 있었나? 로그를 더 들었다. [02:47] Database migration completed [02:48] Automation tests started [02:49] Chrome driver spawned (port 4444) [03:02] Connection pool exhausted아하. 크롬 인스턴스가 정상 종료가 안 되고 있었다. 이전 빌드에서 고아 프로세스가 남아있었다. 메모리를 점점 먹고 있었고, 결국 3시쯤에 터진 거다.이건 내 코드 문제가 아니다. CI 환경 설정 문제다. 하지만 내 책임이다. 왜냐면 내가 테스트를 견고하게 만들지 못했으니까. 새벽 4시, 나는 두 가지를 했다. 1. Dockerfile 수정 # 이전 RUN apt-get install -y chromium-browser# 이후 RUN apt-get install -y chromium-browser RUN echo "pkill -f chrome || true" > /cleanup.shCI 시작 전에 고아 프로세스를 모두 정리하는 스크립트를 넣었다. 2. 테스트 타임아웃 늘림 @pytest.mark.timeout(300) # 5분으로 늘림 def test_checkout_with_coupon(): ...느린 CI 환경을 고려했다. 10초는 너무 짧다. 3. 로그 레벨 상향 logging.basicConfig(level=logging.DEBUG) logger.debug(f"Waiting for {locator}, timeout={timeout}") logger.debug(f"Attempt {attempt+1}/{retries}")다음에 같은 일이 생기면 더 빨리 디버깅할 수 있게. 아침 6시, 결론 다시 실행했다. 이번엔 통과했다. 모든 테스트. 파이프라인이 초록색이 됐다. 아침 9시, 팀 미팅에서 뭐라고 할까 고민했다. 아무것도 안 하기로 했다. 개발팀에게 "야간 파이프라인에 환경 문제가 있었어요"라고 하면 뭐 하냐. 나 혼자 밤새 고칠 수 있는 거다. 대신 테스트 리포트에는 이렇게 적었다. [FIX] Improved wait_for_element stability - Added retry mechanism (3 attempts, 2s interval) - Extended timeout for CI environment - Added debug logging for failed attempts[INFRA] Cleaned up Docker initialization - Added pre-cleanup for orphaned Chrome processes - Improved resource allocation개발팀은 읽지 않을 거다. 하지만 다음 누군가 밤샘할 때 필요한 정보다. 자동화 엔지니어라는 게 이런 거다. 야간 파이프라인의 짐을 혼자 지는 거. 그 대신 그 파이프라인이 믿을 수 있는 파이프라인이 되는 거. 새벽 3시 깨진 테스트는 나한테 뭘 줬나? 2시간의 수면 부채? 아니다. 안정적인 자동화 프레임워크의 법칙 하나를 줬다. 테스트는 최악의 환경을 고려해서 만들어야 한다. 왜냐면 CI 환경은 항상 최악이니까. 오늘따라 커피가 여섯 잔이다.밤샘은 버티는 거지만, 버티는 방법을 배우는 게 진짜 자동화 엔지니어다.[IMAGE_1] [IMAGE_2] [IMAGE_3]