Showing Posts From

전쟁

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와의 전쟁은 계속된다.