Showing Posts From

성공하는데

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리 오전 10시. 출근해서 Jenkins 확인했다. 빨간 불 15개. 어젯밤 배포 파이프라인 전부 터졌다. 슬랙에 개발자들 멘션 폭격. "J님, 테스트 왜 깨진 거예요?" 난 아무것도 안 건드렸다. 어제 퇴근 전에 PR 머지만 했다. 로컬에선 다 초록불이었는데. 로컬은 천국, CI는 지옥 # 로컬에서 100번 돌려도 성공 pytest tests/test_checkout.py # ✓ 20 passed in 45.23s근데 CI에서 돌리면. FAILED tests/test_checkout.py::test_payment_processing ElementNotInteractableException: element not interactable다시 로컬에서 돌린다. 성공한다. 10번 연속 성공. CI 다시 돌린다. 실패한다. 5번 중 3번 실패. 이게 뭐냐고.개발자들은 이런 거 처음 겪나 보다. "제 로컬에선 되는데요?" 알아. 나도 그래. 근데 CI에서 실패하면 배포를 못 한다. 그게 문제다. 첫 번째 용의자: 네트워크 타이밍 로컬에선 API 응답이 빠르다. 개발 서버가 내 컴퓨터 바로 옆이니까. CI는 다르다. 컨테이너끼리 네트워크 통신한다. 지연이 생긴다. # 내가 짠 코드 def test_user_login(): driver.get("https://dev.example.com/login") email_input = driver.find_element(By.ID, "email") email_input.send_keys("test@test.com") # 바로 submit submit_btn = driver.find_element(By.ID, "submit") submit_btn.click()로컬에선 찰나다. 페이지 로드 0.5초. CI에선 2초 걸린다. 그 사이 내 스크립트는 이미 element 찾으러 갔다. 당연히 못 찾는다. 해결법. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECdef test_user_login(): driver.get("https://dev.example.com/login") # 명시적 대기 추가 email_input = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "email")) ) email_input.send_keys("test@test.com") submit_btn = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, "submit")) ) submit_btn.click()이거 하나 바꾸는 데 오전 다 갔다. 테스트 케이스가 300개인데 하나하나 고쳐야 한다.두 번째 용의자: 병렬 실행 순서 로컬에선 테스트를 순차로 돌린다. 한 번에 하나씩. CI는 시간이 금이다. 병렬로 돌린다. 6개 워커. # .gitlab-ci.yml test: script: - pytest -n 6 # 6개 병렬문제는 테스트끼리 간섭한다는 거다. # test_cart.py def test_add_to_cart(): login("test@test.com") add_item("MacBook Pro") assert cart_count() == 1def test_remove_from_cart(): login("test@test.com") remove_item("MacBook Pro") assert cart_count() == 0순차 실행하면 문제없다. 1번 테스트가 끝나고 2번 시작. 병렬 실행하면 꼬인다.워커 1: add_to_cart 실행 중 워커 2: remove_from_cart 동시 실행 둘 다 같은 계정 사용 장바구니 카운트가 왔다갔다결과: 랜덤 실패. 해결법은 격리다. import pytest import uuid@pytest.fixture def unique_user(): email = f"test_{uuid.uuid4()}@test.com" create_test_user(email) return emaildef test_add_to_cart(unique_user): login(unique_user) add_item("MacBook Pro") assert cart_count() == 1각 테스트가 독립적인 데이터를 쓴다. 이제 병렬로 돌려도 안 꼬인다. 근데 이것도 문제다. 테스트 계정 300개가 생성된다. DB cleanup이 필요하다. @pytest.fixture def unique_user(): email = f"test_{uuid.uuid4()}@test.com" user_id = create_test_user(email) yield email delete_test_user(user_id) # teardown이제 CI 실행 시간이 늘었다. cleanup 때문에 1분 추가. 트레이드오프다. 세 번째 용의자: 환경 변수 제일 짜증 나는 케이스. 로컬 .env 파일. API_BASE_URL=http://localhost:3000 DB_HOST=localhost REDIS_URL=redis://localhost:6379CI 환경 변수. variables: API_BASE_URL: http://api-service:3000 DB_HOST: postgres-service REDIS_URL: redis://redis-service:6379URL이 다르다. 당연히 테스트가 깨진다. 더 미묘한 건 이런 거다. import os# 내 코드 def get_api_url(): return os.getenv("API_BASE_URL", "http://localhost:3000")# CI에서는 환경변수가 안 들어간다 # 왜? Docker 컨테이너 안에서 pytest 실행 # 환경변수가 컨테이너 안으로 안 들어감해결법. # .gitlab-ci.yml test: script: - export API_BASE_URL=http://api-service:3000 - pytest또는 docker-compose에서. services: test: environment: - API_BASE_URL=http://api-service:3000이것도 찾는 데 2시간 걸렸다. print 디버깅으로 겨우 찾았다. def test_api_connection(): print(f"API_BASE_URL: {os.getenv('API_BASE_URL')}") # CI 로그 보니까 None이 찍혔다Docker로 환경 동일하게 만들기 결론은 하나다. 로컬 환경을 CI 환경과 똑같이 만든다. Docker Compose 파일 작성. # docker-compose.test.yml version: '3.8'services: app: build: . environment: - API_BASE_URL=http://api:3000 - DB_HOST=postgres depends_on: - postgres - redis - api postgres: image: postgres:14 environment: - POSTGRES_PASSWORD=testpass redis: image: redis:7 api: image: our-api:latest environment: - DB_HOST=postgres이제 로컬에서도 똑같이 돌린다. docker-compose -f docker-compose.test.yml up --build docker-compose -f docker-compose.test.yml run app pytestCI에서도 똑같이. # .gitlab-ci.yml test: script: - docker-compose -f docker-compose.test.yml up -d - docker-compose -f docker-compose.test.yml run app pytest이제 로컬이랑 CI랑 환경이 동일하다. 같은 네트워크 설정. 같은 서비스 이름. 같은 환경 변수. 차이가 없으니 결과도 같다. 그래도 가끔 깨진다 환경을 맞춰도 100% 해결은 아니다. Flaky 테스트는 남는다.CI 서버 CPU가 바쁠 때 타임아웃 외부 API 호출하는 테스트 (네이버 로그인 등) 랜덤 데이터 쓰는 테스트이런 건 다른 방법이 필요하다. 외부 API는 mocking. @patch('requests.get') def test_naver_login(mock_get): mock_get.return_value.json.return_value = { "email": "test@naver.com" } # 실제 네이버 API 안 부름타임아웃은 여유 있게. # Before WebDriverWait(driver, 5) # 5초는 짧다# After WebDriverWait(driver, 15) # 15초로 늘림랜덤은 시드 고정. import random random.seed(42) # 매번 같은 랜덤 값완벽한 해결책은 없다. 그냥 확률을 낮추는 거다. 현재 상태 오후 6시. Jenkins 다시 돌렸다. 초록불 20개. 개발자들 슬랙 반응. "오 고쳤네요!" "ㄳㄳ" 나는 PR에 코멘트 남겼다. 변경사항: - 명시적 대기 추가 (WebDriverWait) - 테스트별 unique user fixture 적용 - docker-compose로 로컬/CI 환경 통일 - 환경변수 주입 확인테스트 시간: 45초 → 1분 20초 (cleanup 때문) 성공률: 60% → 95%완벽하진 않다. 95%. 가끔 1-2개는 여전히 깬다. 근데 이 정도면 된다. Flaky 테스트는 retry로 처리. # .gitlab-ci.yml test: retry: 2 # 2번까지 재시도배운 것 로컬과 CI가 다르다는 걸 인정해야 한다. "내 컴퓨터에선 되는데"는 핑계다. CI 환경을 로컬에서 재현할 수 있어야 한다. Docker가 답이다. 네트워크 타이밍은 항상 고려. 명시적 대기를 쓴다. 테스트는 독립적이어야 한다. 순서에 의존하면 병렬 실행에서 터진다. 환경 변수는 명확하게. print 디버깅으로 값 확인. 100% 안정적인 자동화는 없다. 95%면 성공이다. 나머지 5%는 retry와 monitoring으로.퇴근 7시. 오늘은 정시다. CI 초록불 보니까 기분이 좋다.