스크린샷과 비디오: 실패한 테스트를 디버깅하는 강력한 도구

스크린샷과 비디오: 실패한 테스트를 디버깅하는 강력한 도구

새벽 3시에 깨진 테스트 새벽에 슬랙 알림이 왔다. CI가 깨졌다고. 일어나서 노트북 켰다. 테스트 리포트 열었다. "AssertionError: Expected 'Success' but got 'Error'" 이게 뭐야. 로그만 봐서는 모르겠다. 로컬에서 돌리면 통과한다. 당연히. 이럴 때 필요한 게 스크린샷이다. 그리고 비디오. 실패 순간을 봐야 안다. 다시 잤다. 아침에 보기로.Pytest-html이 필요한 이유 출근해서 다시 봤다. 로그는 여전히 쓸모없다. 그래서 pytest-html을 쓴다. pip install pytest-html pytest --html=report.html --self-contained-html이게 전부다. 설치하고 옵션 주면 끝. 리포트 파일이 하나 나온다. 여는 순간 다르다. HTML이라 브라우저에서 본다.테스트 전체 요약 각 케이스별 실행 시간 실패한 케이스 하이라이트 스택 트레이스 접었다 폈다로그 파일 grep 하던 시절은 끝났다. 근데 이것도 한계가 있다. 텍스트일 뿐. 실패 순간 화면을 못 본다.conftest.py에 스크린샷 로직 스크린샷은 실패할 때만 찍으면 된다. pytest hook을 쓴다. conftest.py에 추가. import pytest from selenium import webdriver import os from datetime import datetime@pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item): outcome = yield report = outcome.get_result() if report.when == 'call' and report.failed: driver = item.funcargs.get('driver') if driver: timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') screenshot_path = f'screenshots/failed_{item.name}_{timestamp}.png' os.makedirs('screenshots', exist_ok=True) driver.save_screenshot(screenshot_path) # HTML 리포트에 추가 extra = getattr(report, 'extra', []) extra.append(pytest_html.extras.image(screenshot_path)) report.extra = extra이제 테스트 실패하면 자동으로 찍힌다. screenshots 폴더에 쌓인다. 파일명에 타임스탬프 넣는 게 포인트. 같은 테스트 여러 번 실패하면 다 남는다. 지난주에 이거로 버그 찾았다. 로딩 스피너가 안 사라진 채로 클릭해서 실패한 거였다. 로그로는 절대 못 찾았다. 비디오 녹화는 더 강력하다 스크린샷은 한 장면만 보여준다. 비디오는 전체 흐름을 본다. Selenium 4부터 녹화가 쉬워졌다. 근데 나는 pytest-xvfb랑 ffmpeg 쓴다. import subprocess import pytestclass VideoRecorder: def __init__(self, test_name): self.test_name = test_name self.process = None def start(self): video_path = f'videos/{self.test_name}.mp4' os.makedirs('videos', exist_ok=True) # Xvfb 디스플레이에서 녹화 self.process = subprocess.Popen([ 'ffmpeg', '-video_size', '1920x1080', '-framerate', '25', '-f', 'x11grab', '-i', ':99', # Xvfb 디스플레이 '-c:v', 'libx264', video_path ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def stop(self): if self.process: self.process.terminate() self.process.wait()@pytest.fixture def video_recorder(request): recorder = VideoRecorder(request.node.name) recorder.start() yield recorder # 실패한 경우만 비디오 보관 if request.node.rep_call.failed: recorder.stop() else: recorder.stop() # 통과한 테스트는 비디오 삭제 video_path = f'videos/{request.node.name}.mp4' if os.path.exists(video_path): os.remove(video_path)이렇게 하면 실패한 테스트만 비디오가 남는다. 용량 문제 해결. 통과한 건 볼 필요 없다. 비디오로 찾은 버그가 있다. 드롭다운 메뉴가 너무 빨리 닫혀서 클릭 실패. 스크린샷으로는 타이밍 이슈 파악 못 했을 거다.원격 디버깅의 현실 재현이 안 되는 버그가 제일 짜증난다. "제 로컬에서는 되는데요?" CI 환경은 다르다. 네트워크 속도 CPU 성능 메모리 브라우저 버전 OS로컬에서 100번 돌려도 통과한다. CI에서는 10번 중 3번 실패한다. Flaky test. 이럴 때 비디오가 답이다. CI에서 녹화해서 아티팩트로 남긴다. # GitHub Actions 예시 - name: Run tests with video run: | pytest --video=on- name: Upload artifacts if: failure() uses: actions/upload-artifact@v3 with: name: test-videos path: videos/ retention-days: 7실패하면 Actions 탭에서 비디오 다운받는다. 재생해서 본다. 문제가 보인다. 지난달에 이거로 해결한 게 있다. 페이지 로드가 완료되기 전에 요소를 찾아서 실패. 비디오 보니까 로딩 인디케이터가 0.2초 늦게 사라졌다. explicit wait 추가했다. 해결. Allure Report는 더 예쁘다 pytest-html도 좋은데 Allure가 더 이쁘다. 회사 비개발자들한테 보여주기 좋다. pip install allure-pytestpytest --alluredir=allure-results allure serve allure-results브라우저가 뜬다. 대시보드가 나온다.통과/실패 비율 파이 차트 시간별 추세 그래프 카테고리별 분류 스크린샷, 비디오 임베드스크린샷 추가는 간단하다. import allure from allure_commons.types import AttachmentType@pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item): outcome = yield report = outcome.get_result() if report.when == 'call' and report.failed: driver = item.funcargs.get('driver') if driver: allure.attach( driver.get_screenshot_as_png(), name='failure_screenshot', attachment_type=AttachmentType.PNG )비디오도 비슷하게 attach 하면 된다. 리포트에서 바로 재생된다. 리드가 "QA 리포트 깔끔하네요" 했다. Allure 덕분이다. 저장 공간 관리는 필수 비디오는 용량 먹는다. Full HD 5분 테스트면 200MB다. 하루에 테스트 100개 돌린다. 10개 실패하면 2GB. 일주일이면 14GB. 스토리지 터진다. 관리가 필요하다. 첫째, 압축한다. # ffmpeg 옵션 조정 '-c:v', 'libx264', '-crf', '28', # 품질 낮춤 (18이 기본, 28이면 용량 반) '-preset', 'veryfast' # 인코딩 속도 우선화질 좀 떨어져도 디버깅에는 문제없다. 용량이 절반으로 줄었다. 둘째, 해상도 낮춘다. 1920x1080 대신 1280x720. 체감 차이 별로 없다. 셋째, 보관 기간 정한다. # 7일 지난 비디오 삭제 import os import timedef cleanup_old_videos(days=7): cutoff = time.time() - (days * 86400) for file in os.listdir('videos'): filepath = os.path.join('videos', file) if os.path.getctime(filepath) < cutoff: os.remove(filepath)매일 아침 cron으로 돌린다. 오래된 거 자동 삭제. 넷째, 실패만 저장한다. 통과한 테스트 비디오는 의미 없다. 위에 fixture에서 이미 처리했다. 지금은 한 달에 30GB 정도 쓴다. S3에 올린다. 비용 한 달에 1달러. 실제로 잡은 버그들 비디오 녹화 도입 후 3개월. 찾은 버그가 확 늘었다. 케이스 1: 무한 로딩증상: 로그인 후 홈 화면 안 뜸 로그: "Timeout waiting for element" 비디오: 로딩 스피너만 10초간 돈다 원인: 백엔드 API 타임아웃 해결: API 팀에 비디오 넘김, 3일 만에 수정케이스 2: 깜빡이는 모달증상: 테스트 5번 중 1번 실패 로그: "Element not clickable" 비디오: 모달이 뜨자마자 사라짐 (0.1초) 원인: CSS transition 버그 해결: 프론트 팀에서 즉시 패치케이스 3: 드래그 앤 드롭 실패증상: 파일 업로드 안 됨 로그: 아무 에러 없음 (가장 최악) 비디오: 드래그는 되는데 드롭 영역이 안 보임 원인: z-index 문제로 드롭존 가림 해결: CSS 수정개발자들한테 비디오 보내니까 반응이 다르다. "아 이거구나" 바로 이해한다. 말로 설명하면 10분. 비디오 보내면 10초. 팀 도입할 때 주의점 처음에 팀원들 반발이 있었다. "설정 복잡해요", "로컬에서 느려요" 천천히 설득했다. 첫째, CI에만 우선 적용. 로컬은 선택사항. 원하면 켜라. 둘째, 간단한 가이드 작성. 3줄로 시작하는 법 정리. # 간단 버전 pip install pytest-html pytest --html=report.html --self-contained-html # 끝셋째, 성공 사례 공유. "이 버그 비디오로 찾았어요" 한 달 후 다들 쓴다. 이제 비디오 없으면 못 일한다. 후배한테 pytest-html 가르쳤다. 30분이면 세팅 끝난다. 어렵지 않다. 비디오는 환경 좀 탄다. Xvfb 설치, ffmpeg 설정. 그래도 공식 문서 따라하면 된다. 내가 쓰는 최종 세팅 정리한다. 지금 내 설정. conftest.py import pytest import allure from datetime import datetime import os@pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item): outcome = yield report = outcome.get_result() if report.when == 'call' and report.failed: driver = item.funcargs.get('driver') if driver: # 스크린샷 allure.attach( driver.get_screenshot_as_png(), name=f'{item.name}_failure', attachment_type=allure.attachment_type.PNG ) # 브라우저 로그 logs = driver.get_log('browser') allure.attach( str(logs), name='browser_logs', attachment_type=allure.attachment_type.TEXT )pytest.ini [pytest] addopts = --alluredir=allure-results --html=report.html --self-contained-html -v --tb=shortCI 설정 (GitHub Actions) - name: Run tests run: pytest- name: Generate Allure report if: always() run: allure generate allure-results- name: Upload report if: always() uses: actions/upload-artifact@v3 with: name: test-report path: allure-report/이게 전부다. 복잡하지 않다. 한 번 세팅하면 끝. 테스트 돌리면 자동으로 스크린샷 찍힌다. 실패하면 비디오 남는다. 리포트는 예쁘게 나온다. 디버깅 시간이 절반으로 줄었다. 버그 재현율이 80%에서 95%로 올랐다. 투자할 가치 있다. 다음 단계 지금은 만족한다. 근데 개선할 점은 있다. 첫째, 실패 비디오 자동 분석. AI로 실패 패턴 찾기. 가능할까? 둘째, 라이브 스트리밍. 테스트 돌 때 실시간으로 보기. CI에서 무슨 일 벌어지는지. 셋째, 네트워크 트래픽 녹화. HAR 파일로 저장. API 응답까지 분석. 욕심은 많다. 시간이 없다. 지금 것만 잘 써도 충분하다.오늘도 테스트 3개 실패했다. 비디오 봤다. 10분 만에 원인 찾았다. 스크린샷 없었으면 하루 걸렸을 거다.

자동화 테스트의 실행 시간이 1시간이 넘어갔을 때

자동화 테스트의 실행 시간이 1시간이 넘어갔을 때

자동화 테스트가 1시간 넘어간 날 아침 9시, 빨간 불 출근했다. 슬랙 알림이 12개다. "Test failed after 1h 42m" 젠킨스 파이프라인이 또 터졌다. 1시간 42분 돌다가 실패. 개발자들 머지 블록당했다. PM이 물어본다. "언제 고쳐지나요?" 모르겠다. 일단 커피부터.작년만 해도 20분이었다. 지금은 1시간 반. 테스트 케이스가 늘어날 때마다 시간도 늘었다. 선형적으로. 문제는 명확했다. 직렬 실행. 800개 테스트가 하나씩 돌아간다. 병렬 처리? 없다. 웨이트 타임? 넘친다. time.sleep(5) 가 237군데. 계산해봤다. 237 × 5초 = 1,185초. 20분이 그냥 기다리는 시간이다. 병렬화 시작 점심 먹고 작업 시작했다. 먼저 pytest-xdist 설치. pytest -n 44개 워커로 돌렸다. 기대했다. 1시간 반이면 20분 되겠지. 결과: 1시간 10분. 왜? 테스트 간 의존성. 3번 테스트가 1번 결과를 쓴다. 병렬로 돌면 깨진다. 격리가 안 돼 있었다. 2시간 걸려서 테스트 12개 수정. DB 픽스처를 각 테스트마다 독립적으로. 공유 상태 제거. 다시 돌렸다. pytest -n 8 --dist loadgroup8개 워커. 결과: 35분. 절반 넘게 줄었다. 개발자가 슬랙에 썸즈업 보냈다.웨이트 지옥 탈출 다음은 대기 시간. time.sleep(5) 를 전부 찾았다. 237개. 복붙의 역사다. # Before driver.click() time.sleep(5) assert element.is_displayed()# After driver.click() WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.ID, "result")) )명시적 대기로 바꿨다. 조건 충족하면 바로 넘어간다. 5초 안 기다려도 된다. 평균 대기 시간이 5초에서 1.2초로 줄었다. 237개 × 3.8초 = 900초 절약. 15분이다. 실행 시간: 20분. 1시간 반에서 20분. 4.5배 빨라졌다. 테스트 선택 실행 그런데 문제가 있었다. PR 하나 올릴 때마다 800개 전부 돌린다. API 하나 고쳤는데 UI 테스트까지 돈다. 비효율이다. 테스트 태깅 시작했다. @pytest.mark.api def test_login_api(): pass@pytest.mark.ui def test_login_ui(): pass@pytest.mark.critical def test_payment(): pass파이프라인 3개로 분리.PR: critical만 (120개, 5분) Merge: api + critical (350개, 12분) Nightly: 전체 (800개, 20분)개발자들 피드백 속도가 확 빨라졌다. PR 올리고 5분이면 결과 나온다.여전히 느린 것들 20분도 여전히 길다는 얘기가 나왔다. 프로파일링 돌렸다. pytest-profiling 설치. pytest --profile결과 보고 놀랐다.test_bulk_upload: 3분 22초 (전체의 16%) test_data_migration: 2분 48초 test_report_generation: 2분 15초3개가 8분을 먹는다. 이건 E2E가 아니라 통합 테스트였다. 실제 10만 건 데이터로 돌리고 있었다. 분리했다. 통합 테스트는 nightly만. E2E는 샘플 데이터 100건으로. 실행 시간: 12분. 캐시와 재사용 다음 최적화. 셋업 시간. 매 테스트마다 브라우저 새로 띄운다. 로그인한다. 메인 페이지 간다. 매번. @pytest.fixture(scope="session") def authenticated_session(): driver = webdriver.Chrome() driver.get("https://app.test") login(driver) yield driver driver.quit()세션 스코프 픽스처. 로그인 한 번만 한다. 800번이 1번으로. 단, 조심해야 한다. 상태 오염. 각 테스트 전에 초기화 필요. @pytest.fixture(autouse=True) def reset_state(authenticated_session): authenticated_session.delete_all_cookies() authenticated_session.get("https://app.test/dashboard")효과: 2분 단축. 실행 시간: 10분. 도커 이미지도 캐싱했다. 테스트 환경 띄우는 데 3분 걸렸는데, 레이어 캐싱으로 30초로 줄었다. Flaky 테스트 지옥 빨라지니까 다른 문제가 보였다. 간헐적 실패. Flaky 테스트. 10번 돌리면 1번 실패한다. 병렬 실행하면서 더 자주 보인다. 타이밍 이슈, 레이스 컨디션. pytest --reruns 3 --reruns-delay 1실패하면 3번 재시도. 그런데 근본 해결은 아니다. 진짜 문제를 찾아야 했다. 일주일간 로그 분석.네트워크 타임아웃: 15건 엘리먼트 로드 타이밍: 23건 DB 트랜잭션 충돌: 8건하나씩 고쳤다. 명시적 대기 추가. 리트라이 로직. DB 격리. Flaky 비율: 12% → 2%. 안정화되니까 신뢰도가 올라갔다. 개발자들이 테스트 결과를 믿는다. 비용 계산 최적화 전후 비교했다. Before:실행 시간: 1시간 42분 하루 10회 실행 EC2 비용: 월 200달러 개발자 대기 시간: 하루 17시간After:실행 시간: 10분 하루 30회 실행 (더 자주 돌림) EC2 비용: 월 80달러 (병렬이지만 짧아서) 개발자 대기 시간: 하루 5시간월 120달러 절약. 더 중요한 건 개발자 12시간 절약. 한 달이면 240시간. 10명이면 2,400시간. 돈으로 환산하면 1억 넘는다. 모니터링 대시보드 지표 추적 시작했다. Grafana 대시보드 만들었다. 매일 확인한다.평균 실행 시간 P95 실행 시간 (상위 5% 느린 케이스) Flaky 비율 병렬화 효율 (이론상 시간 대비 실제) 테스트당 평균 시간그래프가 튀면 조사한다. 느린 테스트 주간 리포트. 팀 회의 때 공유. 개발자들이 테스트 성능도 신경 쓰기 시작했다. "이거 테스트 오래 걸리겠는데요?" 미리 물어본다. 문화가 바뀌었다. 다음 목표 10분도 길다. 다음 최적화 계획:테스트 샤딩 (여러 머신에 분산) 스마트 테스트 선택 (변경된 코드만) 시각적 회귀 테스트 병렬화 더 빠른 브라우저 (headless Chrome → Playwright)목표는 5분. PR 피드백 5분 이내. CTO가 물었다. "테스트를 줄일 수는 없나요?" 아니다. 품질은 타협 못 한다. 대신 빠르게 돌린다. 테스트 800개 유지. 실행 시간만 줄인다. 배운 것 1시간 반이 10분 됐다. 10배 빨라졌다. 한 번에 된 게 아니다. 2달 걸렸다. 매주 조금씩. 병렬화가 가장 효과 컸다. 4.5배. 단, 격리가 전제다. 명시적 대기가 두 번째. 불필요한 시간 제거. 15분 절약. 테스트 선택이 세 번째. 전부 돌릴 필요 없다. 상황에 맞게. 프로파일링이 핵심이다. 추측 말고 측정. 데이터로 판단. 지속적 관리가 필요하다. 한 번 최적화하고 끝이 아니다. 매주 모니터링. 가장 중요한 건, 빠른 피드백이 개발 속도를 올린다는 것. 테스트가 느리면 개발자들이 안 돌린다. 빠르면 자주 돌린다. 자동화의 가치는 속도다. 느린 자동화는 가치가 반감된다.오늘도 테스트는 10분 만에 끝났다. 예전엔 점심 먹고 와도 안 끝났는데.

XPath vs CSS Selector: 언제 뭘 써야 할까

XPath vs CSS Selector: 언제 뭘 써야 할까

XPath vs CSS Selector: 언제 뭘 써야 할까 오늘도 깨진 테스트 출근했다. 슬랙 알람 17개. "자동화 테스트 전부 실패했는데요?" 어제 개발자가 로그인 폼 디자인 살짝 바꿨다. class 이름 하나 변경. 내 테스트 스크립트 83개가 깨졌다. driver.find_element(By.XPATH, "//div[@class='login-container']/form/input[1]")이런 XPath를 썼었다. 멍청했다. 2시간 동안 로케이터 전부 수정했다. 점심도 못 먹었다. 오늘은 이 얘기를 써야겠다고 생각했다. XPath와 CSS Selector. 둘 다 쓸 줄 알지만 언제 뭘 써야 하는지 정확히 아는 사람은 적다. 나도 3년 차까지는 몰랐다.로케이터가 뭔지부터 자동화 테스트에서 웹 요소를 찾는 방법이다. "이 버튼 클릭해" 하려면 일단 그 버튼을 찾아야 한다. Selenium이나 Playwright한테 "여기 있어" 알려주는 게 로케이터다. 방법은 여러 개다.ID Name Class Name Tag Name Link Text XPath CSS SelectorID가 제일 좋다. 유일하고 빠르고 안 깨진다. 근데 현실은 ID 없는 요소가 태반이다. 그래서 XPath나 CSS Selector를 쓴다. 이 둘이 제일 강력하다. 거의 모든 요소를 찾을 수 있다. 문제는 강력할수록 잘못 쓰기 쉽다는 거다. 칼이 날카로울수록 조심해야 하듯이. 나는 신입 때 XPath만 썼다. 개발자 도구에서 Copy XPath 버튼 눌러서 복붙했다. /html/body/div[1]/div[2]/div[3]/form/div[1]/input이런 게 나온다. 겁나 길다. 그리고 UI 조금만 바뀌면 바로 깨진다. 3년 차 되니까 CSS Selector가 더 편하다는 걸 알았다. 4년 차인 지금은 상황에 따라 섞어 쓴다. XPath의 장점과 단점 XPath는 XML Path Language다. HTML은 XML의 일종이니까 쓸 수 있다. 장점부터. 상위로 올라갈 수 있다. # 버튼 찾고 그 부모의 부모 찾기 driver.find_element(By.XPATH, "//button[@id='submit']/../../")CSS Selector는 이게 안 된다. 부모나 형제를 찾을 수 없다. 오직 자식만. 복잡한 DOM 구조에서 특정 요소 기준으로 위로 올라가야 할 때 XPath만 답이다. 텍스트로 찾을 수 있다. # "로그인" 텍스트를 가진 버튼 driver.find_element(By.XPATH, "//button[text()='로그인']")# "확인"을 포함하는 버튼 driver.find_element(By.XPATH, "//button[contains(text(), '확인')]")진짜 편하다. 디자이너가 class 이름 맘대로 바꿔도 텍스트는 잘 안 바뀐다. 복잡한 조건을 쓸 수 있다. # position으로 찾기 //div[@class='item'][position()>2]# 여러 조건 and/or //input[@type='text' and @name='username']이런 건 CSS Selector로 못 한다. 단점도 명확하다. 느리다. CSS Selector보다 평균 10-20% 느리다. 브라우저는 CSS Selector를 네이티브로 지원한다. XPath는 그렇지 않다. 테스트 케이스 10개면 상관없다. 1000개면 체감된다. 문법이 복잡하다. //, /, @, [], .. 이런 기호들. 헷갈린다. 실수하기 쉽다. 신입 QA한테 가르치기도 어렵다. "이건 왜 슬래시가 2개예요?" 질문 받으면 설명이 길어진다. 브라우저마다 미묘하게 다르다. Chrome에서 되는 XPath가 Firefox에서 안 될 때가 있다. 많지는 않지만 가끔 당한다.CSS Selector의 장점과 단점 CSS Selector는 웹 개발자들이 스타일링할 때 쓰는 그거다. 장점. 빠르다. 브라우저 엔진이 직접 지원한다. 최적화도 잘 돼 있다. XPath보다 확실히 빠르다. 대규모 E2E 테스트 스위트 돌릴 때 차이가 난다. 문법이 직관적이다. # ID로 찾기 driver.find_element(By.CSS_SELECTOR, "#username")# Class로 찾기 driver.find_element(By.CSS_SELECTOR, ".login-button")# 속성으로 찾기 driver.find_element(By.CSS_SELECTOR, "input[type='password']")# 자식 찾기 driver.find_element(By.CSS_SELECTOR, "form > input")깔끔하다. 읽기 쉽다. 후배한테 가르치기도 편하다. 크로스 브라우저 호환성이 좋다. CSS는 표준이다. Chrome이든 Firefox든 Safari든 똑같이 작동한다. 단점. 위로 못 올라간다. 부모 선택자가 없다. :has()가 있긴 한데 Selenium에서 지원 안 하는 경우가 많다. 형제 요소 찾기도 제한적이다. 바로 다음 형제만 +로 찾을 수 있다. 텍스트로 못 찾는다. 이게 제일 아쉽다. 버튼 텍스트로 직접 찾을 방법이 없다. # 이런 거 안 됨 driver.find_element(By.CSS_SELECTOR, "button[text='로그인']") # 틀림XPath 써야 한다. 복잡한 조건에 약하다. position이나 조건 로직 같은 건 표현하기 어렵다. 내가 쓰는 기준 4년 동안 삽질하면서 정리한 내 원칙이다. 기본은 CSS Selector. 웬만하면 CSS Selector 쓴다. 빠르고 읽기 쉽고 안정적이다. # 좋음 driver.find_element(By.CSS_SELECTOR, "button[data-testid='submit']") driver.find_element(By.CSS_SELECTOR, ".modal-content input[name='email']")텍스트로 찾아야 하면 XPath. 버튼 라벨, 링크 텍스트, 에러 메시지. 이런 건 텍스트로 찾는 게 제일 안정적이다. # 텍스트 기반 로케이터 driver.find_element(By.XPATH, "//button[text()='다음']") driver.find_element(By.XPATH, "//a[contains(text(), '비밀번호 찾기')]") driver.find_element(By.XPATH, "//span[@class='error' and contains(text(), '필수')]")UI 디자인 바뀌어도 버튼에 쓰인 "다음" 텍스트는 잘 안 바뀐다. 부모/형제 찾아야 하면 XPath. 체크박스 옆의 라벨 텍스트로 체크박스 찾기. 이런 패턴. # 라벨 텍스트로 체크박스 찾기 checkbox = driver.find_element(By.XPATH, "//label[text()='이용약관 동의']/preceding-sibling::input[@type='checkbox']")CSS Selector로는 불가능하다. 테이블 같은 복잡한 구조는 XPath. 특정 행의 특정 열 찾기. position 필요할 때. # 3번째 행의 2번째 셀 cell = driver.find_element(By.XPATH, "//table[@id='data-table']/tbody/tr[3]/td[2]")절대 경로는 쓰지 않는다. # 이런 거 절대 금지 driver.find_element(By.XPATH, "/html/body/div[1]/div[2]/form/input[3]")Copy XPath 해서 나온 거 그대로 쓰면 이렇다. 개발자가 div 하나만 추가해도 깨진다. 상대 경로를 써야 한다. # 이렇게 driver.find_element(By.XPATH, "//form[@id='login-form']//input[@name='username']")안정적인 로케이터 전략 UI 변경에 강한 로케이터를 만드는 게 핵심이다. 1. data-testid 속성을 쓴다. 개발자한테 요청해서 테스트용 속성을 넣어달라고 한다. <button data-testid="submit-button">제출</button>driver.find_element(By.CSS_SELECTOR, "[data-testid='submit-button']")이게 제일 안정적이다. 디자인 바뀌어도 안 깨진다. 우리 팀은 모든 주요 요소에 data-testid 붙이기로 컨벤션 정했다. 개발자들한테 처음엔 귀찮다는 소리 들었다. 지금은 당연하게 생각한다. 2. 의미 있는 속성을 우선한다. 좋은 순서:ID (있으면) data-testid name type + name 조합 class (여러 개 중 의미 있는 것) 텍스트 구조 기반 (최후의 수단)# 좋음 driver.find_element(By.CSS_SELECTOR, "#username") driver.find_element(By.CSS_SELECTOR, "input[name='email']")# 나쁨 driver.find_element(By.CSS_SELECTOR, "div > div > input:nth-child(2)")3. 동적 class는 피한다. # 나쁨 - 빌드마다 바뀌는 해시 driver.find_element(By.CSS_SELECTOR, ".css-1hw23kj-button")# 좋음 - 안정적인 class driver.find_element(By.CSS_SELECTOR, ".primary-button")Tailwind나 CSS Modules 쓰면 class 이름에 해시가 붙는다. 절대 쓰면 안 된다. 4. nth-child는 조심한다. # 위험 - 순서 바뀌면 깨짐 driver.find_element(By.CSS_SELECTOR, "form input:nth-child(2)")# 나음 - 속성으로 특정 driver.find_element(By.CSS_SELECTOR, "form input[type='password']")리스트나 테이블에서 position이 중요한 경우에만 써야 한다. 5. 로케이터를 변수로 관리한다. Page Object Pattern을 쓴다. class LoginPage: USERNAME_INPUT = (By.CSS_SELECTOR, "input[name='username']") PASSWORD_INPUT = (By.CSS_SELECTOR, "input[name='password']") SUBMIT_BUTTON = (By.XPATH, "//button[text()='로그인']") def login(self, username, password): driver.find_element(*self.USERNAME_INPUT).send_keys(username) driver.find_element(*self.PASSWORD_INPUT).send_keys(password) driver.find_element(*self.SUBMIT_BUTTON).click()로케이터 한 곳에서 관리. UI 바뀌면 여기만 수정하면 된다.성능 비교 실험 궁금해서 직접 측정해봤다. 테스트 환경:페이지: 복잡한 대시보드 (DOM 요소 500+) 반복: 각 로케이터 100회 실행 브라우저: Chrome 120결과: ID: 평균 12ms CSS Selector: 평균 18ms XPath (상대경로): 평균 23ms XPath (절대경로): 평균 35msID가 제일 빠르다. 당연하다. CSS Selector가 XPath보다 약 30% 빠르다. 절대 경로 XPath는 두 배 느리다. 쓰면 안 되는 이유가 하나 더. 100회면 차이가 1초. 전체 테스트 스위트 1000개면 10초다. 무시 못 한다. 근데 성능보다 중요한 게 안정성이다. 0.01초 빨라도 매주 깨지면 소용없다. 실제 사례들 사례 1: 동적 폼 회원가입 폼. 선택 항목에 따라 필드가 추가된다. 처음에 이렇게 짰다: # 나쁨 driver.find_element(By.XPATH, "//form/div[3]/input")"기업 회원" 선택하면 사업자등록번호 필드가 생긴다. 그럼 div 순서가 바뀐다. 깨진다. 수정: # 좋음 driver.find_element(By.CSS_SELECTOR, "input[name='phone']") driver.find_element(By.XPATH, "//label[text()='전화번호']/following-sibling::input")name 속성이나 라벨 텍스트로 찾으니 안정적이다. 사례 2: 모달 창 "정말 삭제하시겠습니까?" 확인 모달. 페이지에 여러 모달이 있다. class 이름이 전부 .modal이다. # 나쁨 - 어떤 모달인지 모름 driver.find_element(By.CSS_SELECTOR, ".modal button")첫 번째 모달의 버튼을 찾는다. 원하는 모달이 아닐 수 있다. 수정: # 좋음 - 텍스트로 특정 modal = driver.find_element(By.XPATH, "//div[contains(@class, 'modal') and contains(., '삭제하시겠습니까')]") confirm_button = modal.find_element(By.XPATH, ".//button[text()='확인']")모달 내용으로 찾고, 그 안에서 버튼을 찾는다. 사례 3: 동적 리스트 상품 목록. 개수가 계속 바뀐다. 특정 상품의 "장바구니" 버튼 클릭해야 한다. # 나쁨 driver.find_element(By.XPATH, "//div[@class='product-list']/div[5]/button")5번째 상품이 뭔지 모른다. 상품 추가되면 순서 바뀐다. 수정: # 좋음 product_name = "무선 키보드" button = driver.find_element( By.XPATH, f"//div[contains(@class, 'product-item') and contains(., '{product_name}')]//button[text()='장바구니']" )상품 이름으로 찾으니 확실하다. 디버깅 팁 로케이터 안 될 때 내가 하는 것들. 1. 개발자 도구에서 직접 테스트 Console에서: // CSS Selector 테스트 document.querySelector("input[name='username']")// XPath 테스트 $x("//button[text()='로그인']")결과가 나오면 로케이터는 맞다. 타이밍 문제다. null 나오면 로케이터가 틀렸다. 2. 암묵적 대기 vs 명시적 대기 # 암묵적 대기 - 전역 설정 driver.implicitly_wait(10)# 명시적 대기 - 특정 요소 from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECelement = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='username']")) )동적 로딩이 많으면 명시적 대기가 낫다. 3. Screenshot 찍기 요소 못 찾으면 그 순간 스크린샷 저장. try: driver.find_element(By.CSS_SELECTOR, ".submit-button").click() except NoSuchElementException: driver.save_screenshot("debug.png") raise뭔가 예상과 다르게 렌더링됐는지 바로 알 수 있다. 4. 여러 로케이터 시도 def find_element_flexible(driver, *locators): for locator in locators: try: return driver.find_element(*locator) except NoSuchElementException: continue raise NoSuchElementException(f"None of the locators worked: {locators}")# 사용 button = find_element_flexible( driver, (By.CSS_SELECTOR, "[data-testid='submit']"), (By.XPATH, "//button[text()='제출']"), (By.CSS_SELECTOR, "button.primary") )백업 로케이터를 둔다. 하나 바뀌어도 다른 걸로 찾는다. 팀 컨벤션 우리 팀 규칙. 1. 로케이터 우선순위 1. data-testid (CSS) 2. ID (CSS) 3. name (CSS) 4. 텍스트 (XPath) 5. 복합 속성 (CSS) 6. 구조 + 텍스트 (XPath)이 순서대로 시도. 문서화했다. 2. 절대 경로 금지 코드 리뷰에서 절대 경로 보이면 즉시 reject. # ❌ Rejected "/html/body/div[1]/..."# ✅ Approved "//form[@id='login']//input"3. Page Object 필수 직접 로케이터 쓰지 말고 Page Object 통해서. # ❌ Bad driver.find_element(By.CSS_SELECTOR, "input[name='username']").send_keys("test")# ✅ Good login_page.enter_username("test")4. 로케이터 네이밍 # 명확하게 USERNAME_INPUT = (By.CSS_SELECTOR, "input[name='username']") SUBMIT_BUTTON = (By.XPATH, "//button[text()='로그인']")# 애매하게 말고 INPUT_1 = (By.CSS_SELECTOR, "input:nth-child(1)") # ❌ BTN = (By.XPATH, "//button") # ❌5. 주석으로 이유 설명 특이한 로케이터는 왜 그렇게 했는지 적는다. # XPath 사용 이유: 부모 요소 기준으로 찾아야 함 # 라벨 텍스트로 체크박스 선택 TERMS_CHECKBOX = (By.XPATH, "//label[text()='이용약관 동의']/preceding-sibling::input")실전 체크리스트 로케이터 짤 때 내가 확인하는 것들. 작성 전: 이 요소에 ID나 data-testid 있나? 개발자한테 추가 요청 가능한가? 텍스트 기반으로 찾을 수 있나? 여러 개 있는 요소인가? (리스트, 테이블)작성 중: 절대 경로 안 썼나? 동적 class 안 썼나? nth-child 꼭 필요한가? 더 안정적인 속성 없나?작성 후: 개발자 도구에서 테스트했나? 페이지 새로고침해도 작동하나? 다른 데이터로도 작동하나? (예: 다른 상품명) 백업 로케이터 필요한가?결론 CSS Selector를 기본으로. XPath는 필요할 때만. 빠르고 읽기 쉬운 게 CSS Selector다. 대부분 이걸로 해결된다. 텍스트로 찾거나 부모로 올라가야 하면 XPath 쓴다. 근데 제일 중요한 건 로케이터 종류가 아니다. 얼마나 안정적으로 짜느냐다. ID나 data-testid 쓸 수 있으면 무조건 그거 쓴다. CSS든 XPath든 상관없다. 개발자랑 협업해서 테스트하기 좋은 마크업 만드는 게 제일 중요하다. 로케이터 전략이 없으면 UI 바뀔 때마다 테스트 고친다. 시간 낭비다. 전략 세워두면 테스트가 안정적이다. 자동화가 의미 있어진다. 나도 초반엔 닥치는 대로 Copy XPath 했다. 계속 깨졌다. 지금은 로케이터 짜는 데 시간 쓴다. 그게 나중에 시간 아낀다.오늘 점심 먹으면서 후배한테 이 얘기 했다. "로케이터 전략부터 세워" 라고. 후배는 고개를 끄덕였다. 다음 PR에서 확인할 거다.

테스트 데이터 관리: 공유 DB를 쓸 때의 난장판

테스트 데이터 관리: 공유 DB를 쓸 때의 난장판

새벽 2시, 슬랙 알림 새벽 2시에 슬랙이 울렸다. CI 파이프라인이 깨졌다. 또다. 테스트 실패율 60%. 어제까지 다 돌아갔는데. 로그 봤다. 데이터베이스 충돌. 예상했다. "같은 이메일이 이미 존재합니다." 50개 테스트가 똑같은 에러. test_user@example.com. 우리 팀 모두가 쓰는 그 이메일. 잠이 확 깼다. 노트북 켰다.공유 DB 쓰면서 병렬 테스트 돌리는 거. 이게 문제였다. 8개 테스트가 동시에 돌아간다. 각자 똑같은 이메일로 회원가입 시도. 하나만 성공. 나머지 7개는 실패. 더 웃긴 건, 성공한 그 하나도 결국 실패한다. 다른 테스트가 그 유저를 지워버리니까. 테스트 A가 유저 만들고 → 테스트 B가 그 유저로 로그인 시도 → 테스트 C가 유저 삭제 → 테스트 B 실패. 타이밍 게임이다. 누가 먼저 뭘 하느냐에 따라 결과가 달라진다. Flaky 테스트의 전형. 로컬에선 잘 돌아간다. CI에서만 죽는다. 아침 9시, 데일리 스탠드업 "어제 CI 왜 깨졌어요?" 백엔드 개발자 준수가 물었다. "공유 DB 문제예요. 병렬 테스트 환경에서." "아, 또?" 또. 맞다. 이번 달만 세 번째.우리 QA 환경은 이렇다.개발 서버 1대 공유 MySQL DB 1개 Jenkins에서 8개 워커로 병렬 실행 테스트 스위트 200개 실행 시간: 순차 80분 → 병렬 12분속도는 좋다. 안정성은 최악. "격리된 DB 환경 만들면 되지 않아요?" 준수가 말했다. 쉽게. "Docker 컨테이너로 DB 띄우면요." 알고 있다. 그런데. "컨테이너 8개 띄우면 메모리 부족해요. 서버 스펙이." "아..." 예산 없다. 서버 증설도 없다. 있는 걸로 해결해야 한다. 오전, 문제 분석 커피 들고 책상 앞에 앉았다. 문제를 정리했다. 데이터 충돌 패턴:같은 식별자 사용 (이메일, 전화번호) 순서 의존성 (A 테스트가 B 테스트 데이터 필요) 클린업 실패 (테스트 끝나도 데이터 남음) 타이밍 이슈 (동시 INSERT)우리 테스트 코드를 봤다. def test_user_signup(): email = "test_user@example.com" # 하드코딩 response = signup(email, "password123") assert response.status_code == 200def test_user_login(): email = "test_user@example.com" # 같은 이메일 response = login(email, "password123") assert response.status_code == 200답이 없다. 모든 테스트가 똑같은 데이터. 병렬 실행하면 당연히 깨진다.슬랙에 QA 채널에 물어봤다. "다른 회사는 테스트 데이터 어떻게 관리해요?" 대답이 왔다. "UUID 쓰세요." - 민지 "테스트마다 DB 스키마 분리." - 현우 "우리는 그냥 순차 실행해요." - 수진 각자 다르다. 정답은 없다. 우리 상황에 맞는 걸 찾아야 한다. 점심 후, 전략 세우기 전략을 3개 생각했다. 1. 데이터 고유화 테스트마다 고유한 데이터. UUID나 타임스탬프. import uuiddef test_user_signup(): email = f"test_{uuid.uuid4()}@example.com" response = signup(email, "password123") assert response.status_code == 200장점: 간단. 충돌 없음. 단점: DB에 쓰레기 데이터 쌓임. 클린업 필요. 2. 트랜잭션 롤백 테스트를 트랜잭션으로 감싸고 끝나면 롤백. @pytest.fixture(autouse=True) def db_transaction(): connection.begin() yield connection.rollback()장점: 깔끔. 데이터 안 남음. 단점: API 테스트엔 못 씀. DB 직접 접근만 가능. 우리는 E2E 테스트다. HTTP 요청으로 테스트. 트랜잭션 제어 안 됨. 3. 테스트 격리 환경 각 테스트마다 독립된 DB 스키마나 컨테이너. 장점: 완벽한 격리. 단점: 복잡. 느림. 리소스 많이 먹음. 우리 서버론 무리. 결론: 1번과 2번 혼합.E2E 테스트는 데이터 고유화 + 클린업 유닛 테스트는 트랜잭션 롤백오후, 구현 시작 프레임워크부터 고쳤다. conftest.py에 픽스처 추가했다. import pytest import uuid from datetime import datetime@pytest.fixture def unique_email(): """고유한 테스트 이메일 생성""" timestamp = datetime.now().strftime("%Y%m%d%H%M%S") unique_id = str(uuid.uuid4())[:8] return f"test_{timestamp}_{unique_id}@example.com"@pytest.fixture def unique_phone(): """고유한 테스트 전화번호 생성""" timestamp = datetime.now().strftime("%H%M%S") return f"010{timestamp}{random.randint(1000, 9999)}"@pytest.fixture def test_data_cleanup(request): """테스트 종료 후 데이터 정리""" created_ids = [] def register_for_cleanup(resource_type, resource_id): created_ids.append((resource_type, resource_id)) yield register_for_cleanup # 테스트 끝나면 역순으로 삭제 for resource_type, resource_id in reversed(created_ids): try: cleanup_resource(resource_type, resource_id) except Exception as e: print(f"Cleanup failed: {resource_type} {resource_id} - {e}")테스트 코드를 수정했다. def test_user_signup(unique_email, test_data_cleanup): # 고유 이메일 사용 response = signup(unique_email, "password123") assert response.status_code == 200 user_id = response.json()["user_id"] # 클린업 등록 test_data_cleanup("user", user_id)def test_user_profile_update(unique_email, test_data_cleanup): # 테스트용 유저 생성 user_id = create_test_user(unique_email) test_data_cleanup("user", user_id) # 프로필 수정 테스트 response = update_profile(user_id, {"name": "테스터"}) assert response.status_code == 200200개 테스트 다 고쳐야 한다. 한숨 나왔다. 2시간 후, 첫 번째 벽 테스트 50개 고쳤다. 돌려봤다. 새로운 문제. 외래 키 제약. 유저 삭제하려는데 주문 데이터가 남아있다. 삭제 실패. IntegrityError: Cannot delete user - foreign key constraint fails삭제 순서가 중요하다. 주문 → 장바구니 → 결제수단 → 유저. 이 순서로. cleanup_resource 함수를 고쳤다. def cleanup_resource(resource_type, resource_id): """리소스 타입별 정리 로직""" cleanup_order = { "order": ["payment", "order_item", "order"], "user": ["order", "cart", "payment_method", "user"], "product": ["order_item", "cart_item", "product"] } if resource_type in cleanup_order: for dep_type in cleanup_order[resource_type]: delete_dependent_resources(dep_type, resource_id) delete_resource(resource_type, resource_id)복잡하다. DB 스키마를 다 알아야 한다. 의존성 그래프 그렸다. A4 용지 3장. 4시, 두 번째 벽 테스트 100개 고쳤다. 돌려봤다. 또 실패. 이번엔 타임아웃. 클린업에 시간이 너무 오래 걸린다. 각 테스트가 끝날 때마다 5초씩. 200개면 1000초. 16분 추가. 병렬 실행 효과가 없다. 최적화가 필요했다. 방법 1: 배치 삭제 테스트마다 지우지 말고 모아서 한 번에. @pytest.fixture(scope="session") def global_cleanup(): """전체 테스트 세션 종료 후 일괄 정리""" cleanup_list = [] yield cleanup_list # 타입별로 그룹핑 by_type = {} for resource_type, resource_id in cleanup_list: by_type.setdefault(resource_type, []).append(resource_id) # 타입별 배치 삭제 for resource_type, ids in by_type.items(): batch_delete(resource_type, ids)문제: 테스트 실패하면 데이터가 남는다. 다음 실행에 영향. 방법 2: 타임스탬프 기반 정리 오래된 테스트 데이터만 주기적으로 삭제. def cleanup_old_test_data(): """1시간 이상 된 테스트 데이터 삭제""" cutoff_time = datetime.now() - timedelta(hours=1) # 이메일에 타임스탬프 포함되어 있으면 파싱 old_users = find_users_by_email_pattern( "test_%@example.com", created_before=cutoff_time ) for user in old_users: delete_user_cascade(user.id)Jenkins 크론잡 추가. 매시간 실행. 이게 더 현실적이다. 5시, 세 번째 벽 테스트 150개 고쳤다. 새로운 패턴 발견. 순서 의존성. test_user_login이 test_user_signup에 의존한다. 병렬 실행하면 순서 보장 안 됨. 로그인 테스트가 먼저 실행돼서 실패. pytest 마커로 해결했다. @pytest.mark.dependency() def test_user_signup(unique_email): # 회원가입 테스트 pass@pytest.mark.dependency(depends=["test_user_signup"]) def test_user_login(unique_email): # 로그인 테스트 pass근데 이건 순차 실행이다. 병렬의 의미가 없다. 테스트를 다시 짰다. 독립적으로. def test_user_login(unique_email, test_data_cleanup): # 로그인 테스트용 유저를 여기서 생성 user_id = create_test_user(unique_email, "password123") test_data_cleanup("user", user_id) # 로그인 테스트 response = login(unique_email, "password123") assert response.status_code == 200각 테스트가 필요한 전제 조건을 스스로 만든다. Setup이 길어진다. 대신 독립적이다. 트레이드오프다. 6시, 전체 테스트 실행 200개 다 고쳤다. 전체 실행. 숨 참고 봤다. Jenkins 로그가 올라간다. Test Session Summary 200 tests collected 8 workers Execution time: 15m 23s성공률 95%. 10개가 여전히 실패한다. 로그 확인. 여전히 데이터 충돌. 상품 테스트. 같은 상품명 쓰고 있었다. def test_product_create(): product_name = "테스트 상품" # 하드코딩 response = create_product(product_name, 10000) assert response.status_code == 200이것도 고유화 필요. def test_product_create(unique_product_name, test_data_cleanup): response = create_product(unique_product_name, 10000) assert response.status_code == 200 product_id = response.json()["product_id"] test_data_cleanup("product", product_id)unique_product_name 픽스처 추가. 다시 실행. 성공률 98%. 4개 남았다. 진짜 버그들이다. API에 동시성 문제가 있었다. 같은 상품을 동시에 두 번 생성하면 둘 다 성공한다. 버그 티켓 올렸다. 저녁 7시, 회고 준수한테 슬랙 보냈다. "CI 이제 안정적이에요. 성공률 98%." "오, 뭘 했어요?" "테스트 데이터 격리. 고유화랑 클린업 전략." "시간은 얼마나 걸려요?" "15분. 전보다 3분 늘었어요." "그 정도면 괜찮네요." 실제로 한 것들 정리했다. 데이터 고유화 전략:UUID 기반 이메일/전화번호 생성 타임스탬프 조합 테스트 실행 ID 포함클린업 전략:픽스처 기반 자동 정리 의존성 순서 고려한 삭제 타임아웃 방지 위한 배치 처리 크론잡으로 오래된 데이터 정리격리 전략:테스트 간 데이터 공유 최소화 각 테스트가 독립적으로 실행 가능 Setup에서 필요한 데이터 생성 Teardown에서 확실한 정리아직 남은 문제:클린업 실패 시 복구 로직 부족 DB 커넥션 풀 고갈 가능성 테스트 데이터 증가로 DB 용량 이슈 격리 환경 구축은 여전히 숙제밤 9시, 집에서 퇴근하고 생각했다. 완벽한 해결책은 없다. 컨테이너 기반 격리가 이상적이지만 현실적으로 불가능. 우리처럼 공유 DB 쓰는 곳 많다. 비용 때문에. 인프라 제약 때문에. 그럼 최선을 찾아야 한다. 핵심은 3가지:데이터 고유성: 절대 충돌하지 않게 독립성: 테스트가 다른 테스트에 의존하지 않게 정리: 쓰레기 안 남기게이것만 지켜도 80%는 해결된다. 나머지 20%는 비즈니스 로직의 동시성 문제다. 테스트가 찾아낸 진짜 버그. 오히려 좋다. 테스트의 본질이니까. 다음 스프린트엔 Docker Compose 로컬 환경 구축 시도해볼 것. 개발자들 로컬에서라도 격리된 DB 쓰게. CI는... 예산 나올 때까지 지금 방식으로. 슬랙에 QA 채널에 썼다. "테스트 데이터 관리 가이드 문서 작성했어요. 컨플루언스에 올렸습니다." 후배들이 같은 삽질 안 했으면 좋겠다. 테스트 자동화는 코드만의 문제가 아니다. 데이터, 환경, 인프라 모두 고려해야 한다. 그게 SDET의 영역이다. 내일은 Flaky 테스트 리포트 만들어야지. 무작위로 실패하는 것들 분류하고. 노트북 닫았다. 충분히 했다.공유 DB 쓰면서 병렬 테스트 돌리는 거, 생각보다 복잡하다. 데이터 격리만 잘해도 절반은 해결되는데.

야근하며 수정한 테스트: 내일 아침 더 꼬여 있다

야근하며 수정한 테스트: 내일 아침 더 꼬여 있다

오후 6시, 급한 불 배포가 내일이다. PM이 슬랙에 멘션을 날렸다. "J님, 새 결제 기능 자동화 테스트 내일 아침까지 가능할까요?" 가능하냐고? 불가능하다. 하지만 대답은 정해져 있다. "네, 해보겠습니다." 정상적으로 하면 이틀 걸린다. API 스펙 확인하고, 테스트 시나리오 짜고, Page Object 설계하고, 스크립트 작성하고, 리뷰받고. 그게 정석이다. 지금 시간은 6시 반. 퇴근 시간 지났다. 옆자리 개발자들 하나둘 나간다. "먼저 갈게요." "네, 수고하세요." 모니터 3개를 켰다. 왼쪽엔 API 문서, 가운데엔 IDE, 오른쪽엔 테스트 실행 화면. 커피 한 잔 더 탔다. 네 번째다.급하게 짜는 코드의 특징 8시다. 일단 돌아가는 걸 만들었다. def test_payment(): driver.get("https://...") driver.find_element_by_id("btn_pay").click() time.sleep(5) # 일단 5초 기다림 assert "success" in driver.page_source보기 싫다. 하드코딩 범벅이다. sleep으로 때웠다. Page Object도 없다. 그냥 element 직접 찾는다. 하지만 돌아간다. 지금은 그게 중요하다. 다음 시나리오. 카드 결제 실패 케이스. def test_payment_fail(): driver.get("https://...") driver.find_element_by_id("card_num").send_keys("1234") # 잘못된 카드 driver.find_element_by_id("btn_pay").click() time.sleep(5) assert "실패" in driver.page_source복붙했다. 앞에 코드 거의 똑같다. URL도 하드코딩, 셀렉터도 하드코딩. fixture도 안 만들었다. "나중에 리팩토링하면 되지." 이 말을 몇 번 했는지 모른다. 경력 7년차가 이러면 안 되는데. 9시 반. 테스트 케이스 5개 짰다. 전부 이런 식이다. 코드 중복 천지. 하드코딩 천국. sleep 축제. 돌려봤다. 3개 성공, 2개 실패. 실패한 이유를 봤다. 타이밍 이슈다. sleep(5)가 부족했다. 10으로 늘렸다. 다시 돌렸다. 4개 성공, 1개 실패. 또 타이밍이다. sleep(15)로 늘렸다. 전부 통과했다. "좋아, 일단 된다." 커밋했다. 메시지는 "add payment test cases". 상세 설명 없다. 급하니까.다음날 아침의 충격 출근했다. 10시 10분. 늦었다. 어젯밤 12시에 퇴근했다. 슬랙을 켰다. 알림 7개. 전부 Jenkins 실패 알림이다. "어? 어제 다 통과했는데?" CI 로그를 봤다. 전부 타임아웃이다. selenium.common.exceptions.TimeoutException Element not found: btn_pay아, 맞다. 하드코딩했지. 개발자가 어젯밤에 버튼 ID를 바꿨다. btn_pay에서 button_payment로. 슬랙에 개발자 메시지가 있다. "결제 버튼 컴포넌트 리팩토링했습니다. UI 변경 없어요." UI는 안 바뀌었다. 하지만 ID는 바뀌었다. 그걸 하드코딩했으니 깨진다. 수정했다. 5군데. 다시 푸시. 다시 실행. 또 실패. 이번엔 다른 에러. AssertionError: 'success' not found in page source페이지 구조가 바뀌었다. 성공 메시지가 모달로 뜬다. page_source엔 안 보인다. 수정했다. explicit wait 추가. 모달 찾는 로직 추가. 그런데 다른 테스트도 깨졌다. 전부 같은 패턴으로 짰으니까. 10시 반. 벌써 30분 날렸다. PM이 슬랙에 물었다. "J님 테스트 통과했나요? 배포 11시예요." "조금만요, 거의 다 됐습니다." 거짓말이다. 아직 멀었다.기술 부채의 이자 점심시간이다. 배포는 연기됐다. 내 때문이다. 테스트는 겨우 통과시켰다. 하지만 더 꼬였다. 어젯밤 코드 위에 오늘 아침 땜질을 했다. 나쁜 코드 위에 더 나쁜 코드. def test_payment(): driver.get("https://...") time.sleep(3) # 페이지 로딩 btn = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "button_payment")) ) btn.click() time.sleep(5) # 결제 처리 try: modal = driver.find_element_by_class_name("modal_success") assert "success" in modal.text except: assert "success" in driver.page_source # fallbacksleep이랑 explicit wait이 섞였다. try-except로 땜질했다. fallback 로직도 이상하다. 이게 5개 테스트에 복붙되어 있다. 팀장이 코드 리뷰를 달았다. "J님, 이거 Page Object 패턴 안 쓴 이유가 있나요?" "급해서 일단 돌아가게 만들었습니다. 리팩토링할게요." "배포 끝나고 꼭 해주세요. 이렇게 가면 유지보수 힘듭니다." 안다. 나도 안다. 하지만 리팩토링할 시간이 있을까? 다음주에 또 급한 기능이 온다. 또 급하게 짤 것이다. 또 부채가 쌓일 것이다. 악순환의 시작 2주 지났다. 결제 테스트는 그대로다. 리팩토링 할 시간이 없었다. 새 기능 테스트 짜느라. 그것도 급하게 짰다. 같은 패턴으로. 이제 급한 코드가 10개 파일이다. CI 실행시간이 20분에서 35분으로 늘었다. sleep 때문이다. Flaky 테스트가 생겼다. 타이밍 이슈로 가끔 실패한다. 개발자들이 불평한다. "테스트 왜 이렇게 느려요?" "가끔 이유 없이 실패하던데, 테스트 문제 아닌가요?" 변명할 수 없다. 내 코드가 문제다. 새로 온 QA 후배가 내 코드를 봤다. "선배님, 이 테스트 왜 이렇게 짜셨어요? Page Object 안 쓰셨네요?" "응, 급해서... 나중에 고칠 거야." "그럼 제가 새로 짤 테스트도 이렇게 짜면 돼요?" "아니, 너는 제대로 짜. 이건... 나쁜 예시야." 후배한테 나쁜 예시를 보여주고 있다. 부채의 이자는 복리다 한 달 지났다. 급한 코드가 20개 파일이 됐다. 이제 신규 기능 하나 추가하려면 기존 코드를 봐야 한다. 복붙할 게 많으니까. 그러다 나쁜 패턴도 같이 복붙된다. API가 바뀌면 20군데를 고쳐야 한다. 하드코딩했으니까. UI 컴포넌트가 바뀌면 테스트 10개가 깨진다. 셀렉터를 직접 박았으니까. CI 실행시간은 50분이다. sleep 총합이 5분이 넘는다. 테스트 커버리지는 올랐다. 65%. 좋아 보인다. 하지만 품질은 떨어졌다. 테스트를 믿을 수 없다. 개발자들이 테스트 실패를 무시한다. "저 테스트 원래 잘 깨지잖아요. 그냥 재실행하면 돼요." 틀린 말이 아니다. PM이 물었다. "테스트 자동화 많이 했는데, 버그는 왜 여전히 나와요?" 대답할 수 없었다. 리팩토링의 두려움 주말이다. 집에서 쉬고 있다. 리팩토링을 해야 한다는 생각이 머리를 떠나지 않는다. 계획을 세웠다.Page Object 패턴 적용 공통 fixture 만들기 sleep 전부 explicit wait로 교체 하드코딩된 셀렉터 전부 상수로 분리 중복 코드 제거최소 3일은 걸린다. 풀타임으로. 하지만 3일을 쓸 수 있을까? 그 사이에 버그가 나면? 긴급 패치가 들어가면? 새 기능이 또 급하게 오면? 리팩토링 도중에 기존 테스트는 돌아가지 않는다. CI가 깨진다. 팀장한테 말해야 한다. "3일간 다른 일 못 합니다. 리팩토링합니다." 승인이 날까? 더 큰 두려움이 있다. 리팩토링 하다가 기존 테스트를 깨뜨리면 어떡하지? 지금 코드가 나쁘지만, 어쨌든 돌아간다. 고치다가 더 망가뜨리면? "그냥 새로 짜는 게 나을까?" 하지만 새로 짜는 것도 3일이다. 똑같다. 결국 아무것도 안 했다. 월요일이 됐다. 작은 리팩토링의 시작 월요일 오전. 회의가 없다. 결심했다. 전체는 못 해도 일부는 한다. 가장 자주 깨지는 테스트 파일 하나만. test_payment.py. 2시간 걸렸다. Page Object 만들었다. class PaymentPage: BTN_PAYMENT = (By.ID, "button_payment") MODAL_SUCCESS = (By.CLASS_NAME, "modal_success") def __init__(self, driver): self.driver = driver def click_payment(self): element = WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable(self.BTN_PAYMENT) ) element.click() def get_result_message(self): modal = WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located(self.MODAL_SUCCESS) ) return modal.text테스트 코드가 깔끔해졌다. def test_payment_success(payment_page): payment_page.click_payment() result = payment_page.get_result_message() assert "success" in resultsleep이 사라졌다. 중복도 사라졌다. 읽기 쉽다. 돌려봤다. 통과했다. CI에도 통과했다. 실행 시간이 3분 줄었다. 기분이 좋다. 오후에 또 다른 파일 하나. test_cart.py. 같은 방식으로. 2시간 반 걸렸다. 이번엔 조금 빨랐다. 패턴을 알았으니까. 하루에 파일 2개씩 리팩토링한다. 10일이면 20개 끝난다. 점진적 개선 2주 지났다. 급한 코드의 절반을 고쳤다. 완벽하지 않다. 아직 개선할 부분이 많다. 하지만 확실히 나아졌다. CI 실행시간이 35분으로 줄었다. Flaky 테스트가 5개에서 2개로 줄었다. 코드 리뷰에서 지적받는 횟수가 줄었다. 후배가 말했다. "선배님, 이번 코드는 이해하기 쉬워요. 이렇게 짜면 되는 거죠?" "응, 이게 맞아." 개발자가 슬랙에 썼다. "테스트 빨라졌네요. 뭐 하셨어요?" "리팩토링 좀 했습니다." "좋네요. ㅎㅎ" 작은 칭찬이지만 기분 좋다. 팀장이 1on1에서 말했다. "테스트 코드 품질 올라간 거 보여요. 꾸준히 하고 있죠?" "네, 조금씩 하고 있습니다." "급하다고 대충 짜는 거, 나도 이해해요. 하지만 결국 돌아오더라고요." "맞아요. 배웠습니다." "이번 분기 평가에 반영할게요. 수고하고 있어요." 배운 것들 급한 코드는 빚이다. 언젠가 갚아야 한다. 이자는 복리다. 급한 코드 위에 급한 코드가 쌓인다. 한 번에 갚을 순 없다. 파산한다. 조금씩 갚는 수밖에 없다. "나중에 리팩토링하면 돼"는 거짓말이다. 나중은 안 온다. 더 급한 일만 온다. 그래서 매일 조금씩 해야 한다. 출근해서 1시간. 파일 하나. 함수 몇 개. 그럼 부채가 줄어든다. 천천히지만 확실하게. 완벽한 코드를 짤 순 없다. 급할 때도 있다. 하지만 부채를 인정하고 관리해야 한다. 급하게 짰으면 주석을 남긴다. "TODO: refactor to Page Object". 급하게 짰으면 이슈를 만든다. "기술 부채: test_payment 리팩토링". 급하게 짰으면 다음날 1시간 투자한다. 조금이라도 개선한다. 그게 쌓이면 달라진다. 지금 내 코드는 한 달 전보다 낫다. 완벽하지 않다. 아직 급한 코드가 10개 남았다. 하지만 매일 조금씩 나아진다. 그걸로 충분하다.어젯밤 야근해서 짠 코드. 오늘 아침엔 더 꼬여 있다. 그게 부채의 이자다. 하지만 매일 조금씩 갚으면, 언젠가 0이 된다. 그걸 믿고 오늘도 리팩토링 한다.