Showing Posts From

자동화

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요?

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요?

CI에서 깨졌는데 로컬에서는 왜 된다고 나와요? 새벽 2시의 슬랙 알람 슬랙이 울렸다. 새벽 2시. "Jenkins Build #847 Failed" 침대에서 폰 켰다. 빨간 불. 또다. 로컬에서는 분명 다 통과했다. 커밋 푸시 전에 세 번 돌렸다. 전부 초록불. 근데 CI에서는 깨졌다. 이게 벌써 이번 주만 다섯 번째다. "환경 차이요? 그게 뭔데요?" 신입이 물었다. 어제. 설명했다. 30분. 근데 오늘 또 똑같은 문제로 슬랙이 왔다. 그래서 쓴다. 이 글. 로컬은 네 컴퓨터, CI는 남의 집 로컬에서 테스트 돌린다. MacBook Pro. 16GB 램. Python 3.9.7. Chrome 119. Jenkins에서 테스트 돌린다. Ubuntu 20.04. 8GB 램. Python 3.9.2. Chrome 118. 다르다. 당연히 다르다. 근데 개발자들은 모른다. "똑같은 코드잖아요." 코드는 같다. 환경이 다르다.Python 버전부터 다르다 로컬: Python 3.9.7 Jenkins: Python 3.9.2 "마이너 버전 차이인데요?" 맞다. 근데 dict 순서 보장이 3.9.2에서 미묘하게 달랐다. 테스트가 dict key 순서에 의존하고 있었다. 로컬에서는 통과. CI에서는 실패. 원인 찾는 데 4시간 걸렸다. 크롬 버전도 다르다 Selenium으로 E2E 테스트 돌린다. 로컬에서는 크롬 자동 업데이트 됐다. 119. Jenkins에서는 Docker 이미지에 박혀 있다. 118. 버튼 클릭 타이밍이 달라졌다. WebDriverWait 타임아웃이 로컬에서는 충분했다. 3초. Jenkins에서는 부족했다. 5초 필요. 타임존이 다르다 로컬: Asia/Seoul (UTC+9) Jenkins: UTC 날짜 테스트가 깨졌다. datetime.now() 썼다. 로컬에서는 오늘. Jenkins에서는 어제. assert 실패. 가장 흔한 5가지 원인 이제 패턴이 보인다. 7년 하니까. 1. 환경 변수가 없다 로컬에는 .env 파일 있다. API_KEY=abc123 DB_HOST=localhost TIMEOUT=30Jenkins에는 없다. 당연히 없다. 깃에 안 올렸으니까. 테스트가 환경 변수 읽는다. None 나온다. 터진다. 해결책: Jenkins 환경 변수 설정. Credentials Plugin 쓴다. 시크릿 관리. 근데 매번 까먹는다. 새 변수 추가하면. 체크리스트 만들었다. 커밋 전에 확인.2. 파일 경로가 절대경로다 테스트 코드에 이렇게 썼다. test_data = '/Users/jiyeon/project/test_data.json'로컬에서는 된다. 내 맥북 경로니까. Jenkins에서는 안 된다. Jenkins 서버에 그 경로 없으니까. FileNotFoundError. 상대경로 써야 한다. test_data = os.path.join(os.path.dirname(__file__), 'test_data.json')이것도 자주 실수한다. 후배들이. 코드 리뷰 때마다 지적한다. 3. 네트워크가 다르다 로컬에서는 회사 내부망 접속 된다. VPN 연결돼 있어서. Jenkins는 AWS에 있다. 내부망 접속 안 된다. 스테이징 서버 API 호출이 안 된다. 타임아웃 난다. 60초 기다리다가 실패. 해결책: Jenkins에서도 VPN 연결 설정. 또는 테스트용 API 엔드포인트 따로 만들기. 근데 이거 DevOps팀이랑 협의 필요하다. 귀찮다. 4. 캐시가 남아있다 로컬에서는 pytest 캐시 쌓인다. .pytest_cache/ 폴더. 이전 테스트 결과 기억한다. Jenkins에서는 매번 clean build. 캐시 없다. 테스트가 캐시에 의존하고 있었다. 몰랐다. Jenkins에서만 실패. pytest --cache-clear 로컬에서 돌려봤다. 재현됐다. 5. Docker 컨테이너 리소스 부족 Jenkins에서 Docker 컨테이너로 테스트 돌린다. 메모리 제한 걸려있다. 2GB. 로컬에서는 제한 없다. 16GB 다 쓴다. Selenium 테스트 10개 동시 실행. 로컬: 문제없음. Jenkins: OOMKilled. 메모리 터졌다. 해결책: 병렬 실행 수 줄이기. 또는 컨테이너 메모리 늘리기. 디버깅 방법론 패턴 알았으니 대응한다. 1단계: 로컬에서 CI 환경 재현 Jenkins에서 쓰는 Docker 이미지 똑같이 받는다. docker pull jenkins/jenkins:lts로컬에서 같은 이미지로 컨테이너 띄운다. docker run -it jenkins/jenkins:lts /bin/bash그 안에서 테스트 돌린다. 재현되면 환경 문제 확정.2단계: 환경 변수 출력 테스트 시작할 때 환경 정보 전부 출력한다. import os import sys import platformprint(f"Python: {sys.version}") print(f"OS: {platform.system()} {platform.release()}") print(f"ENV: {os.environ}")로컬 출력이랑 Jenkins 출력 비교한다. 차이 찾는다. 보통 여기서 원인 나온다. 3단계: 단계별 로그 추가 테스트 실패하는 부분 찾는다. 그 앞뒤로 로그 추가한다. logger.info("Before API call") response = api.get('/endpoint') logger.info(f"Response: {response.status_code}") logger.info(f"Body: {response.text}")Jenkins 콘솔 로그 본다. 어디서 멈췄는지 안다. 타임아웃인지, 에러인지, 응답이 다른지. 4단계: 환경 통일 자동화 매번 수동으로 맞추기 귀찮다. 자동화한다. Docker Compose 쓴다. version: '3' services: test: image: python:3.9.7 environment: - TZ=Asia/Seoul - API_KEY=${API_KEY} volumes: - .:/app command: pytest tests/로컬에서도 이걸로 돌린다. Jenkins에서도 이걸로 돌린다. 환경 일치. 환경 변수 관리하는 법 제일 골치 아픈 부분이다. .env 파일 vs Jenkins Credentials 로컬 개발: .env 파일 CI/CD: Jenkins Credentials Plugin 문제: 변수 추가할 때 두 곳 다 업데이트해야 함. 자주 까먹는다. 해결책: 변수 목록 문서화. README에 필수 환경 변수 리스트 적는다. ## Required Environment Variables - API_KEY: API 인증 키 - DB_HOST: 데이터베이스 호스트 - TIMEOUT: 테스트 타임아웃 (초)신입이 보고 설정할 수 있게. 민감 정보 관리 API 키, DB 비밀번호. 깃에 올리면 안 된다. 로컬: .env (gitignore에 추가) Jenkins: Credentials 저장 테스트 코드에서는 환경 변수로만 접근. api_key = os.getenv('API_KEY') if not api_key: raise ValueError("API_KEY not set")없으면 즉시 실패. 명확한 에러 메시지. "근데 Jenkins에서는 어떻게 주입하나요?" Jenkinsfile에서. withCredentials([string(credentialsId: 'api-key', variable: 'API_KEY')]) { sh 'pytest tests/' }이렇게. Docker로 환경 통일하기 가장 확실한 방법. Dockerfile 작성 테스트 전용 이미지 만든다. FROM python:3.9.7# 타임존 설정 ENV TZ=Asia/Seoul RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime# 크롬 설치 RUN apt-get update && apt-get install -y \ wget \ gnupg \ && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ && apt-get update \ && apt-get install -y google-chrome-stable# 의존성 설치 COPY requirements.txt . RUN pip install -r requirements.txtWORKDIR /app로컬에서도 이걸로 돌린다. docker build -t test-env . docker run -v $(pwd):/app test-env pytestJenkins에서도 이걸로 돌린다. docker.build('test-env').inside { sh 'pytest tests/' }환경 일치. 문제 사라짐. Docker Compose로 전체 스택 띄우기 DB, Redis 필요한 테스트 있다. 로컬에서 직접 설치 귀찮다. Docker Compose 쓴다. version: '3' services: db: image: postgres:13 environment: POSTGRES_PASSWORD: test redis: image: redis:6 test: build: . depends_on: - db - redis environment: DB_HOST: db REDIS_HOST: redis volumes: - .:/app실행. docker-compose up --abort-on-container-exit test전체 환경 일관성 있게 띄워짐. 로컬이든 Jenkins든 똑같이. 타임존과 날짜 테스트 자주 무시되는 부분. 근데 자주 터진다. 문제 상황 def test_today(): today = datetime.now().date() assert get_today_logs() == today로컬: Asia/Seoul. 2024-01-15 14:00. Jenkins: UTC. 2024-01-15 05:00. datetime.now() 결과 다르다. assert 실패. 해결책 1: UTC로 통일 모든 시간을 UTC로. from datetime import datetime, timezonedef test_today(): today = datetime.now(timezone.utc).date() assert get_today_logs() == today서버도 UTC. 테스트도 UTC. 통일. 해결책 2: 환경 변수로 타임존 설정 Docker에서 타임존 주입. environment: - TZ=Asia/Seoul이러면 컨테이너 내부 시간이 서울 시간. 근데 권장은 UTC 통일. 서버 시간은 항상 UTC가 표준이니까. 네트워크 이슈 대응 API 테스트할 때 자주 나온다. 문제: 내부망 접근 불가 로컬: 회사 네트워크. 내부 API 접근 가능. Jenkins: AWS. 내부망 차단. 테스트 실패. 해결책 1: VPN 설정 Jenkins 서버에서 VPN 연결. 근데 복잡하다. DevOps 도움 필요. 해결책 2: 테스트용 공개 엔드포인트 스테이징 서버를 외부 접근 가능하게. 보안팀 승인 받아야 한다. 귀찮다. 해결책 3: Mock 사용 외부 API는 Mock으로. @patch('requests.get') def test_api_call(mock_get): mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {'data': 'test'} result = fetch_data() assert result == {'data': 'test'}네트워크 의존성 제거. 가장 안정적. 근데 실제 API 동작은 못 테스트한다. Trade-off. 실전 체크리스트 커밋 전에 확인한다. 환경 독립성 체크 절대경로 사용 안 함 환경 변수 의존성 문서화 .env.example 파일 업데이트 로컬 캐시에 의존 안 함CI 재현 테스트 Docker로 로컬 재현 성공 환경 변수 누락 없음 타임존 영향 확인 네트워크 의존성 명확화로그 및 디버깅 실패 시 충분한 로그 출력 환경 정보 출력 코드 추가 타임아웃 값 명시적 설정이거 지키면 90% 해결된다. 나머지 10%는 경험. 결론 "CI에서 깨졌는데 로컬에서는 돼요." 이제 안 무섭다. 체계적으로 접근한다.환경 차이 파악 Docker로 재현 로그로 원인 찾기 환경 통일 자동화시간은 걸린다. 근데 한 번 세팅하면 끝. 그 다음부터는 편하다. 신입 후배한테 이 글 링크 보낸다. 다음에 또 물어보면.새벽 2시 슬랙. 이제 덜 무섭다. 체크리스트 있으니까.

매뉴얼 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 써요?" "이 구조는 왜 이렇게 짰어요?" 또 내 코드 다시 볼 거다. 또 리팩토링할 거다. 가르치는 게 배우는 거다. 민지가 증명했다.민지 질문이 내 코드를 고쳤다. 다음 신입도 기대된다.