Showing Posts From

Selenium

테스트 자동화 프레임워크 선택: Selenium vs Playwright vs Cypress

테스트 자동화 프레임워크 선택: Selenium vs Playwright vs Cypress

프레임워크 전쟁의 시작 월요일 오전. 슬랙에 CTO 메시지가 떴다. "J님, Playwright 도입 검토 좀 해주세요." 커피 한 모금도 안 마셨다. 머리가 복잡해졌다. 우리 자동화 프레임워크는 Selenium 기반이다. 4년 전에 내가 직접 구축했다. 테스트 케이스 1200개. 커버리지 65%. 매일 밤 돌아간다. 문제없이 돌아간다. 그런데 최근 개발자들이 계속 물어본다. "Playwright는 왜 안 써요?" "Cypress가 더 빠르다던데요?" "Selenium은 옛날 거 아닌가요?" 짜증 났다. 그래서 제대로 정리하기로 했다.Selenium: 내 4년의 동반자 우리 프레임워크는 Selenium + Python + Pytest다. 현재 상태:테스트 케이스: 1200개 실행 시간: 약 3시간 (병렬 처리) 안정성: 85% (flaky test 때문에) 유지보수 시간: 주 10시간솔직히 말하면 Selenium은 늙었다. 2004년부터 있었다. 거의 20년 됐다. 장점이 명확하다:브라우저 지원이 최고다. Chrome, Firefox, Safari, Edge, 심지어 IE도 된다. 우리 고객 중 10%가 아직 IE 쓴다. 믿기지 않지만 사실이다.생태계가 방대하다. 스택오버플로우에 답이 다 있다. 에러 메시지 복사해서 검색하면 해결법 나온다. 이게 얼마나 중요한지 모른다.언어 선택의 자유. Python, Java, JavaScript, C#, Ruby. 뭐든 된다. 우리는 Python이다. 백엔드팀이 Python이라 코드 공유가 쉽다.모바일도 된다. Appium이 Selenium 기반이다. 웹/앱 자동화 프레임워크 통합이 가능하다.단점도 분명하다:느리다. WebDriver 통신 방식이 문제다. 브라우저 ↔ WebDriver ↔ 테스트 코드. 중간 단계가 많다. 네트워크 레이턴시가 쌓인다.셋업이 복잡하다. ChromeDriver 버전 맞추기가 지옥이다. Chrome 자동 업데이트되면 테스트 깨진다. CI 환경에서 드라이버 관리가 일이다.Flaky test 지옥. time.sleep(2) 이딴 게 코드에 수십 개다. 요소 로딩 타이밍 잡기가 어렵다. WebDriverWait 써도 완벽하지 않다.에러 메시지가 불친절하다. "Element not found". 그래서 어디서 왜 없는 건데? 디버깅에 시간 배로 든다.매주 flaky test 잡는데 3시간 쓴다. 진짜 스트레스다.Playwright: 떠오르는 강자 팀 막내가 사이드 프로젝트로 Playwright 써봤다고 했다. "진짜 빨라요. 셋업도 쉽고요." 그래서 직접 테스트해봤다. 일주일 동안 POC 진행했다. 첫인상이 강렬했다: # Selenium driver = webdriver.Chrome() driver.get("https://example.com") element = driver.find_element(By.ID, "button") element.click()# Playwright page.goto("https://example.com") page.click("#button")코드가 간결하다. 보일러플레이트가 적다. 장점:속도가 미쳤다. 브라우저랑 직접 통신한다. CDP(Chrome DevTools Protocol) 쓴다. WebDriver 없다. 같은 테스트가 40% 빠르다.자동 대기가 똑똑하다. page.click()하면 알아서 요소 나타날 때까지 기다린다. time.sleep() 필요 없다. Flaky test가 확 줄어든다.셋업이 쉽다. playwright install 하면 끝이다. 브라우저 바이너리를 직접 다운로드한다. 버전 걱정 없다.병렬 처리가 강력하다. 브라우저 컨텍스트 격리가 잘 된다. 세션 충돌 없다. 테스트 속도가 배로 빨라진다.디버깅 툴이 좋다. Playwright Inspector가 있다. 스텝별로 실행하고 요소 하이라이트된다. 스크린샷 자동 저장. 비디오 녹화도 된다.API 테스트도 된다. playwright.request로 API 호출 가능하다. E2E + API 통합 테스트가 한 프레임워크에서 된다.단점:브라우저 제한. Chromium, Firefox, WebKit만 된다. IE 안 된다. Safari는 WebKit이지만 진짜 Safari랑 다르다. 우리 10% 고객은?생태계가 작다. 2020년에 나왔다. 4년밖에 안 됐다. 스택오버플로우 답변이 적다. 이상한 버그 만나면 혼자 해결해야 한다.학습 곡선. Selenium 아는 사람이 많다. Playwright는 새로 배워야 한다. 팀 온보딩 시간이 든다.Microsoft 의존. MS가 만들었다. 오픈소스지만 결국 MS 생태계다. TypeScript 푸시가 강하다. Python 지원은 2등이다.실제로 로그인 테스트 5개를 마이그레이션 해봤다. 결과:작성 시간: Selenium 3시간 → Playwright 1.5시간 실행 속도: 45초 → 18초 Flaky 발생: 3회 → 0회솔직히 놀랐다.Cypress: 프론트엔드의 사랑 프론트엔드 개발자들은 Cypress를 좋아한다. "저희 로컬에서 개발하면서 바로 테스트 돌려요." 그게 Cypress의 철학이다. 개발자 경험에 집중한다. 특징:실시간 리로딩. 코드 저장하면 브라우저가 자동으로 테스트 다시 돌린다. TDD 하기 좋다.타임 트래블. 테스트 각 단계로 돌아갈 수 있다. DOM 스냅샷이 저장된다. 디버깅이 직관적이다.네트워크 모킹이 쉽다. cy.intercept()로 API 응답을 가짜로 만든다. 백엔드 없이 프론트 테스트 가능하다.DX가 최고다. 문서가 친절하다. 에러 메시지가 구체적이다. 커뮤니티가 활발하다.치명적 단점:단일 도메인만 된다. 탭 전환 안 된다. 다른 도메인 이동하면 꼬인다. OAuth 로그인 테스트가 어렵다. 우리는 Google 로그인 쓴다. 불가능하다.백엔드 테스트 약하다. API 테스트가 메인이 아니다. 프론트 중심이다.병렬 처리가 유료다. Cypress Dashboard 써야 한다. 월 75달러부터 시작이다. 오픈소스로는 순차 실행만 된다.브라우저 제한. Chrome, Firefox, Edge만 된다. Safari 안 된다.우리 테스트 시나리오 중 30%가 멀티 도메인이다. Cypress는 답이 안 나온다. 프론트 개발자들 로컬 테스트용으로는 좋다. E2E 메인 프레임워크로는 부족하다. 실전 비교: 같은 테스트를 세 가지로 공정한 비교를 위해 실험을 했다. 시나리오: 로그인 → 대시보드 → 리포트 생성 → 다운로드 → 로그아웃 복잡도 중간. 우리 일반적 테스트다. 개발 시간:Selenium: 4시간 (웨이트 튜닝에 1시간) Playwright: 2시간 Cypress: 3시간 (다운로드 처리 까다로움)실행 시간 (10회 평균):Selenium: 42초 Playwright: 18초 Cypress: 25초안정성 (50회 반복):Selenium: 43회 성공 (86%) Playwright: 50회 성공 (100%) Cypress: 48회 성공 (96%)코드 라인 수:Selenium: 85줄 Playwright: 52줄 Cypress: 58줄Playwright가 압도적이었다. 디버깅 시간 (의도적 버그 삽입):Selenium: 평균 12분 Playwright: 평균 5분 (Inspector 덕분) Cypress: 평균 6분 (타임 트래블 덕분)숫자는 거짓말 안 한다. 마이그레이션 시뮬레이션 CTO가 원한 건 현실적 검토였다. 우리 상황:테스트 케이스: 1200개 일일 커밋: 평균 25개 QA 팀: 4명 (자동화 담당 나 포함 2명) 예산: 넉넉하지 않음 타임라인: 분기별 목표 있음Playwright로 전환 시: Phase 1 (1개월):신규 기능 테스트만 Playwright로 작성 기존 Selenium 유지 병렬 운영 학습 기간 포함Phase 2 (2개월):Critical path 20% 마이그레이션 로그인, 결제, 회원가입 등 성공률 모니터링 팀 피드백 수집Phase 3 (3개월):나머지 80% 점진적 전환 주 30개씩 변환 Selenium deprecated 공지총 소요: 6개월 비용:개발 시간: 480시간 (나 + 동료) 급여 환산: 약 2400만원 CI 인프라 조정: 300만원 교육/학습: 무형 비용기대 효과:테스트 실행 시간: 3시간 → 1.2시간 (60% 단축) Flaky test: 15% → 3% (80% 감소) 유지보수 시간: 주 10시간 → 4시간 (60% 단축)ROI를 계산했다. 6개월 투자로 이후 매주 6시간 절약. 1년이면 312시간. 내 시급 3만원으로 계산하면 936만원. 2년이면 1872만원. 투자 대비 회수 기간: 약 18개월. 나쁘지 않다. 팀 설득 작업 수요일 오후. QA 팀 회의. "Playwright로 가는 거 어떻게 생각해?" 반응이 갈렸다. 매뉴얼 QA 출신 후배 (경력 2년): "Selenium도 이제 겨우 익숙해졌는데요... 또 배워야 해요?" 자동화 동료 (경력 5년): "좋긴 한데 IE 고객은요? 그냥 Selenium 4로 업그레이드하는 건요?" 신입 (경력 6개월): "저는 Playwright가 더 쉬운 것 같던데요. 학원에서 배울 때도 그게 더 쉬웠어요." 각자 입장이 다르다. 개발팀 회의도 했다. 프론트 리드: "좋아요. 저희는 Cypress 쓰고, QA는 Playwright 쓰고. 통합은 안 해요?" 백엔드 리드: "E2E가 빨라지면 저희 PR 리뷰가 빨라지나요? 그럼 찬성이에요." DevOps: "CI 파이프라인 다시 짜야 하는 거죠? 시간 주세요." CTO한테 보고했다. "투자 대비 효과가 명확하네요. 진행하세요. 단, 기존 테스트 안정성 떨어지면 안 됩니다." 압박이다. 현실적 결론 금요일 저녁. 결정을 내렸다. 선택: Playwright 이유:속도와 안정성. 숫자가 증명한다. 논쟁 여지 없다.장기 투자. 6개월 고생하면 이후 2년 편하다. 이직해도 이력서에 최신 기술 쓴다.팀 성장. 새 기술 배우는 게 동기부여된다. 다들 지루해하고 있었다.트렌드. 2024년 기준 Playwright가 대세다. GitHub 스타 60k. Selenium 30k 정체 중.단, 조건:IE 고객 예외 처리. 해당 기능만 Selenium 유지. 별도 파이프라인. 10%를 위해 90% 희생 안 한다.점진적 전환. 빅뱅 금지. 한 번에 바꾸면 망한다. 스프린트당 5% 목표.롤백 플랜. 실패하면 Selenium 복귀. 자존심 버린다.문서화. 모든 변경사항 기록. 다음 사람을 위해.Selenium을 버리는 게 아니다:모바일 테스트(Appium)는 계속 Selenium 기반 레거시 브라우저 테스트는 유지 스킬은 여전히 가치 있다Cypress는? 프론트팀 로컬 개발용으로 권장한다. E2E 메인은 Playwright. 역할이 다르다. 월요일부터 시작한다. 첫 주는 학습. 튜토리얼 돌리고 팀 세션 연다. 두려움 반 설렘 반이다. 1주일 후 실제로 시작했다. 신규 기능 "알림 설정" 테스트를 Playwright로 짰다. 소요 시간:예상: 3시간 실제: 5시간처음이라 헤맸다. 하지만 결과는 좋았다. Playwright 코드: def test_notification_settings(page): page.goto("/settings") page.click("text=알림 설정") page.check("#email-notification") page.click("button:has-text('저장')") # 자동 대기. time.sleep 없음. expect(page.locator(".success-message")).to_be_visible()깔끔하다. 읽기 쉽다. 같은 테스트 Selenium이었으면: def test_notification_settings(driver): driver.get("/settings") WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.LINK_TEXT, "알림 설정")) ).click() time.sleep(1) # 페이지 전환 대기 checkbox = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "email-notification")) ) checkbox.click() time.sleep(0.5) # 체크박스 애니메이션 driver.find_element(By.XPATH, "//button[contains(text(), '저장')]").click() time.sleep(2) # 저장 처리 대기 message = WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.CLASS_NAME, "success-message")) ) assert message.is_displayed()차이가 명확하다. 팀 동료가 코드 리뷰했다. "오... 진짜 짧네요. 이게 돌아가요?" 돌아간다. 50번 돌렸다. 50번 성공했다. 후배가 물었다. "저도 다음 테스트는 Playwright로 해봐도 돼요?" "해봐. 막히면 불러." 변화가 시작됐다. 2주 후 첫 위기 CI에서 Playwright 테스트가 터졌다. 로컬에서는 되는데 CI에서 실패한다. 클래식한 문제다. 에러: browserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1097/chrome-linux/chrome브라우저 바이너리가 없다. CI 설정 문제였다. 해결: # .github/workflows/test.yml - name: Install Playwright run: | pip install playwright playwright install --with-deps chromium--with-deps 옵션이 핵심이다. 시스템 의존성까지 설치한다. 다시 돌렸다. 성공했다. 하지만 시간이 오래 걸렸다. CI 실행 시간:Selenium: 15분 Playwright: 22분뭐가 문제인가. 프로파일링 했다. 브라우저 설치 시간이 7분이었다. 최적화: - name: Cache Playwright browsers uses: actions/cache@v3 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('**/requirements.txt') }}캐싱 추가했다. 결과:첫 실행: 22분 이후 실행: 8분Selenium보다 빠르다. DevOps 담당자가 만족했다. "CI 비용도 줄겠는데요?" GitHub Actions는 분 단위 과금이다. 월 500달러 쓰고 있었다. 40% 단축이면 월 200달러 절약이다. 연간 2400달러. 320만원. 부수 효과였다. 1개월 후 중간 점검 신규 테스트 25개를 Playwright로 짰다. 통계:평균 작성 시간: 2.2시간 (Selenium 대비 40% 단축) 평균 실행 시간: 15초 (Selenium 대비 65% 단축) Flaky 발생: 0건 팀 만족도: 5점 만점에 4.2점좋다. 하지만 문제도 있었다. 이슈 1: 스크린샷 용량 Playwright는 실패 시 자동으로 스크린샷을 찍는다. 좋은 기능이다. 하지만 CI 아티팩트 용량이 터졌다. 해결: # pytest.ini [pytest] playwright_screenshot = only-on-failure playwright_video = retain-on-failure필요한 것만 저장한다. 이슈 2: 팀 학습 곡선 매뉴얼 QA 출신 후배가 고전했다. "Locator가 뭐예요? Selector랑 다른 건가요?" Playwright의 개념이 Selenium과 달랐다. 해결: 매주 금요일 1시간 세션을 열었다.Week 1: Locator vs Selector Week 2: Auto-waiting 원리 Week 3: API testing Week 4: 디버깅 Tips효과가 있었다. 후배가 처음으로 Playwright 테스트를 혼자 완성했다. "생각보다 쉬워요. 웨이트 신경 안 써도 되니까 편해요." 성장이 보였다. 이슈 3: Selenium 테스트 방치 신규 테스트는 Playwright로 짠다. 기존 Selenium 테스트는 그대로다. 문제는 유지보수였다. 개발자가 UI를 바꾸면 Selenium 테스트가 깨진다. 고치기 귀찮다. Playwright로 다시 짜고 싶다. 하지만 계획은 점진적 전환이었다. 해결: 우선순위를 정했다.Critical path (결제, 로그인): 즉시 전환 자주 깨지는 테스트: 다음 분기 안정적인 테스트: 마지막급하게 하지 않는다. CTO한테 중간 보고했다. "순항 중입니다. 예상보다 빠릅니다." "좋네요. 근데 ROI는 언제 나와요?" "3개월 후부터 유지보수 시간이 줄어들 겁니다." "기대하겠습니다." 압박은 계속된다. 3개월 후 전환점 기존 Selenium 테스트 중 200개를 Playwright로 전환했다. 전체의 약 17%. 목표치(15%) 초과했다. 효과가 나타났다: 유지보수 시간:이전: 주 10시간 현재: 주 6.5시간Flaky test 비율:이전: 15% 현재: 8%CI 실행 시간:이전: 180분 현재: 145분숫자로 증명됐다. 결정적 사건: 대규모 UI 리뉴얼이 있었다. 디자인 시스템 전면 교체. 버튼, 입력창, 모달 전부 바뀌었다. Selenium 테스트 1000개가 다 깨졌다. 끔찍했다. 복구 시간 예상:Selenium: 약 80시간 (ID, 클래스명 다 바뀜) Playwright: 약 30시간 (텍스트 기반 Locator 많이 씀)실제로는:Selenium: 75시간 (4일) Playwright: 25시간 (1.5일)차이가 극명했다. CTO가 인정했다. "전환 잘한 것 같네요." 팀 사기도 올랐다. "다음 분기엔 더 많이 전환해볼까요?" 속도가 붙었다. 남은 과제들 아직 해결 못한 것들이 있다. 1. IE 고객 여전히 10%다. 줄지 않는다. 해당 기능은 Selenium 유지 중이다. 별도 파이프라인 돌린다. 언젠가는 IE 지원 중단할 것이다. 그때까지는 이중 운영이다. 2. 모바일 앱 Appium은 여전히 Selenium 기반이다. Playwright에 모바일 지원이 실험 단계에 있다. 아직 프로덕션 레디는 아니다. 당분간은 분

새 기능이 나올 때마다 테스트 스크립트를 처음부터 짜야 할까

새 기능이 나올 때마다 테스트 스크립트를 처음부터 짜야 할까

새 기능 나올 때마다 테스트 스크립트 처음부터 짜는 거 아니다 처음엔 나도 매번 새로 짰다 입사 첫 해. 새 기능 나오면 테스트 스크립트를 처음부터 작성했다. 로그인 테스트 짜면서 로그인 함수 또 만들고. 검색 기능 테스트하면서 검색 함수 또 만들고. 같은 코드를 계속 복붙했다. 어느 날 PM이 물었다. "새 기능 테스트 자동화 언제 돼요?" 2주 걸린다고 했더니 놀랐다. "자동화인데 왜 그렇게 오래 걸려요?" 그때 깨달았다. 내 자동화는 자동화가 아니었다. 테스트 케이스만 자동으로 돌아갈 뿐, 스크립트 작성은 수동이었다. 퇴근길에 생각했다. 개발자들은 라이브러리 쓰잖아. 매번 HTTP 요청 코드 새로 안 짜잖아. 나도 그렇게 해야 하는 거다.공통 함수부터 만들었다 다음 날부터 공통 함수를 모으기 시작했다. # common/auth.py 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() wait_for_dashboard(driver)간단했다. 로그인 기능을 함수 하나로. 이제 테스트마다 4줄이 1줄로 줄었다. login(driver, "test@test.com", "password123") 검색도 마찬가지였다. # common/search.py def search_product(driver, keyword): search_box = driver.find_element(By.ID, "search") search_box.clear() search_box.send_keys(keyword) search_box.send_keys(Keys.RETURN) wait_for_results(driver)처음엔 이것만으로도 신기했다. 테스트 작성 시간이 반으로 줄었다. 하지만 문제가 생겼다. ID가 "username"에서 "user-email"로 바뀌었다. 로그인 쓰는 테스트가 100개. 함수 하나만 고치면 끝났다. 그때 알았다. 재사용은 유지보수를 위한 거다.Page Object Model을 배웠다 선배가 코드 리뷰하면서 말했다. "POM 써봤어?" Page Object Model. 페이지를 클래스로 만드는 패턴이었다. # pages/login_page.py class LoginPage: def __init__(self, driver): self.driver = driver self.username_field = (By.ID, "username") self.password_field = (By.ID, "password") self.login_button = (By.ID, "login-btn") def login(self, username, password): self.driver.find_element(*self.username_field).send_keys(username) self.driver.find_element(*self.password_field).send_keys(password) self.driver.find_element(*self.login_button).click() return DashboardPage(self.driver)처음엔 복잡해 보였다. 함수 하나로 충분한데 왜 클래스로 만들어. 하지만 써보니 달랐다. # tests/test_login.py def test_successful_login(): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()읽기 쉬웠다. 테스트가 뭘 하는지 한눈에 보였다. 로그인하고, 대시보드 뜨는지 확인하고. 더 중요한 건 확장성이었다. 로그인 페이지에 소셜 로그인 버튼이 추가됐다. def login_with_google(self): self.driver.find_element(*self.google_button).click() # OAuth 처리 return DashboardPage(self.driver)LoginPage 클래스에 메서드만 추가하면 됐다. 기존 테스트는 안 건드렸다. 컴포넌트를 더 작게 쪼갰다 POM도 중복이 있었다. 모든 페이지에 공통 헤더가 있었다. 로고, 검색창, 프로필 메뉴. 매 페이지마다 헤더 요소를 정의하는 건 비효율적이었다. # components/header.py class Header: def __init__(self, driver): self.driver = driver self.search_box = (By.ID, "global-search") self.profile_menu = (By.ID, "profile-menu") def search(self, keyword): element = self.driver.find_element(*self.search_box) element.send_keys(keyword) element.send_keys(Keys.RETURN) def open_profile(self): self.driver.find_element(*self.profile_menu).click() return ProfilePage(self.driver)페이지 클래스는 컴포넌트를 포함했다. # pages/dashboard_page.py class DashboardPage: def __init__(self, driver): self.driver = driver self.header = Header(driver) self.sidebar = Sidebar(driver) def search_from_dashboard(self, keyword): self.header.search(keyword) return SearchResultsPage(self.driver)이제 헤더가 바뀌어도 Header 클래스만 수정하면 됐다. 10개 페이지를 안 건드렸다. 컴포넌트 단위로 생각하니 UI도 다르게 보였다. 모달, 툴팁, 드롭다운. 전부 재사용 가능한 컴포넌트였다. # components/modal.py class Modal: def __init__(self, driver): self.driver = driver self.close_button = (By.CLASS_NAME, "modal-close") self.confirm_button = (By.CLASS_NAME, "modal-confirm") def close(self): self.driver.find_element(*self.close_button).click() def confirm(self): self.driver.find_element(*self.confirm_button).click()어떤 모달이든 이 클래스로 처리했다. 삭제 확인 모달, 저장 확인 모달, 에러 모달.공통 액션을 모듈화했다 테스트를 짜다 보면 패턴이 보였다. 기다리고, 클릭하고, 확인하고. 특히 기다리는 코드가 많았다. 로딩 끝날 때까지, 버튼 활성화될 때까지, 데이터 나올 때까지. # utils/waits.py class CustomWait: def __init__(self, driver, timeout=10): self.driver = driver self.wait = WebDriverWait(driver, timeout) def until_visible(self, locator): return self.wait.until(EC.visibility_of_element_located(locator)) def until_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def until_text_present(self, locator, text): return self.wait.until(EC.text_to_be_present_in_element(locator, text)) def until_loading_done(self): self.wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "loading-spinner")))이제 테스트마다 WebDriverWait 안 써도 됐다. waiter = CustomWait(driver) waiter.until_loading_done() element = waiter.until_clickable(login_button)스크롤도 자주 썼다. 무한 스크롤, 특정 요소까지 스크롤, 맨 위로 스크롤. # utils/actions.py class Actions: def __init__(self, driver): self.driver = driver def scroll_to_element(self, element): self.driver.execute_script("arguments[0].scrollIntoView(true);", element) def scroll_to_bottom(self): self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") def infinite_scroll_until(self, target_count, item_locator): while True: items = self.driver.find_elements(*item_locator) if len(items) >= target_count: break self.scroll_to_bottom() time.sleep(1)테스트 코드가 깔끔해졌다. 의도가 명확했다. actions = Actions(driver) actions.infinite_scroll_until(50, (By.CLASS_NAME, "product-item"))"50개 아이템 나올 때까지 스크롤한다." 코드가 주석이 됐다. 데이터 생성도 라이브러리화했다 테스트마다 데이터가 필요했다. 유저, 상품, 주문. 처음엔 테스트 파일마다 데이터를 만들었다. test_user = { "email": "test@test.com", "password": "password123", "name": "테스터" }100개 테스트에 100개 유저가 있었다. 유저 스키마가 바뀌면 100군데를 고쳐야 했다. 팩토리 패턴을 적용했다. # fixtures/user_factory.py class UserFactory: @staticmethod def create_user(role="user", **kwargs): default_user = { "email": f"test_{uuid.uuid4()}@test.com", "password": "password123", "name": "테스트 유저", "role": role } default_user.update(kwargs) return default_user @staticmethod def create_admin(**kwargs): return UserFactory.create_user(role="admin", **kwargs)테스트에선 간단히 호출했다. user = UserFactory.create_user() admin = UserFactory.create_admin(name="관리자")이메일은 자동으로 유니크했다. 역할별로 쉽게 만들었다. 필요한 필드만 오버라이드했다. 상품도 마찬가지였다. # fixtures/product_factory.py class ProductFactory: @staticmethod def create_product(category="electronics", **kwargs): default_product = { "name": f"테스트 상품 {uuid.uuid4().hex[:8]}", "price": 10000, "stock": 100, "category": category, "description": "테스트용 상품입니다" } default_product.update(kwargs) return default_product재고 없는 상품, 할인 중인 상품, 카테고리별 상품. 필요한 데이터를 즉시 만들었다. out_of_stock = ProductFactory.create_product(stock=0) on_sale = ProductFactory.create_product(price=5000, original_price=10000)API 호출도 재사용했다 E2E 테스트 전에 데이터 세팅이 필요했다. UI로 만들면 너무 오래 걸렸다. API로 직접 데이터를 만들었다. # api/user_api.py class UserAPI: def __init__(self, base_url, token=None): self.base_url = base_url self.token = token def create_user(self, user_data): response = requests.post( f"{self.base_url}/api/users", json=user_data, headers={"Authorization": f"Bearer {self.token}"} ) return response.json() def delete_user(self, user_id): requests.delete( f"{self.base_url}/api/users/{user_id}", headers={"Authorization": f"Bearer {self.token}"} )테스트 전에 유저를 API로 만들고, 테스트 후에 삭제했다. @pytest.fixture def test_user(): user_api = UserAPI(BASE_URL, ADMIN_TOKEN) user_data = UserFactory.create_user() created_user = user_api.create_user(user_data) yield created_user user_api.delete_user(created_user["id"])테스트 시간이 70% 줄었다. UI로 회원가입하면 30초, API로 만들면 1초. 주문도 API로 만들었다. 장바구니 담고, 결제하고, 주문 완료까지. UI로 하면 2분, API로 하면 5초. 설정도 중앙화했다 환경별로 URL이 달랐다. 개발, 스테이징, 운영. 처음엔 테스트마다 URL을 하드코딩했다. 환경 바뀌면 전부 수정해야 했다. 설정 파일을 만들었다. # config/config.py class Config: ENVIRONMENTS = { "dev": { "base_url": "https://dev.example.com", "api_url": "https://api-dev.example.com", "db_host": "dev-db.example.com" }, "staging": { "base_url": "https://staging.example.com", "api_url": "https://api-staging.example.com", "db_host": "staging-db.example.com" }, "prod": { "base_url": "https://example.com", "api_url": "https://api.example.com", "db_host": "prod-db.example.com" } } @classmethod def get_config(cls, env="dev"): return cls.ENVIRONMENTS.get(env)테스트 실행할 때 환경만 지정했다. pytest --env=stagingconftest.py에서 설정을 로드했다. # conftest.py def pytest_addoption(parser): parser.addoption("--env", action="store", default="dev")@pytest.fixture(scope="session") def config(request): env = request.config.getoption("--env") return Config.get_config(env)@pytest.fixture def driver(config): driver = webdriver.Chrome() driver.get(config["base_url"]) yield driver driver.quit()이제 모든 테스트가 설정을 참조했다. 환경 바뀌어도 코드는 안 바뀌었다. 커스텀 어서션을 만들었다 Pytest 기본 assert도 좋지만, 반복되는 체크가 있었다. 요소가 화면에 보이는지, 특정 텍스트를 포함하는지, 특정 상태인지. # assertions/custom_assertions.py class CustomAssertions: def __init__(self, driver): self.driver = driver def assert_element_visible(self, locator, message=""): element = self.driver.find_element(*locator) assert element.is_displayed(), f"Element {locator} not visible. {message}" def assert_text_contains(self, locator, expected_text): element = self.driver.find_element(*locator) actual_text = element.text assert expected_text in actual_text, \ f"Expected '{expected_text}' in '{actual_text}'" def assert_element_count(self, locator, expected_count): elements = self.driver.find_elements(*locator) actual_count = len(elements) assert actual_count == expected_count, \ f"Expected {expected_count} elements, found {actual_count}"테스트가 읽기 쉬워졌다. assertions = CustomAssertions(driver) assertions.assert_element_visible((By.ID, "welcome-message")) assertions.assert_text_contains((By.CLASS_NAME, "username"), "테스터") assertions.assert_element_count((By.CLASS_NAME, "product-item"), 20)에러 메시지도 명확했다. 어떤 요소가, 뭘 기대했고, 실제론 뭐였는지. 베이스 테스트 클래스를 만들었다 모든 테스트가 공통으로 하는 일이 있었다. 드라이버 시작, 로그인, 종료. 베이스 클래스로 추상화했다. # tests/base_test.py class BaseTest: @pytest.fixture(autouse=True) def setup(self, driver, config): self.driver = driver self.config = config self.waiter = CustomWait(driver) self.actions = Actions(driver) self.assertions = CustomAssertions(driver) # 공통 페이지 객체 self.login_page = LoginPage(driver) yield # 스크린샷 저장 (실패 시) if hasattr(self, '_test_failed') and self._test_failed: self.save_screenshot() def save_screenshot(self): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.driver.save_screenshot(f"screenshots/failed_{timestamp}.png") def login_as_user(self, user=None): if not user: user = UserFactory.create_user() self.login_page.login(user["email"], user["password"]) return DashboardPage(self.driver)테스트 클래스는 BaseTest를 상속했다. class TestDashboard(BaseTest): def test_dashboard_loads(self): self.login_as_user() self.assertions.assert_element_visible((By.ID, "dashboard")) def test_search_from_dashboard(self): dashboard = self.login_as_user() dashboard.header.search("테스트") self.waiter.until_loading_done() self.assertions.assert_element_count((By.CLASS_NAME, "result-item"), 10)setup, teardown, 공통 헬퍼가 자동으로 제공됐다. 테스트는 비즈니스 로직에만 집중했다. 라이브러리 구조를 정리했다 코드가 많아지니 구조가 중요해졌다. 어디에 뭐가 있는지 한눈에 봐야 했다. automation/ ├── pages/ │ ├── login_page.py │ ├── dashboard_page.py │ └── product_page.py ├── components/ │ ├── header.py │ ├── sidebar.py │ └── modal.py ├── api/ │ ├── user_api.py │ ├── product_api.py │ └── order_api.py ├── fixtures/ │ ├── user_factory.py │ └── product_factory.py ├── utils/ │ ├── waits.py │ ├── actions.py │ └── db_helper.py ├── assertions/ │ └── custom_assertions.py ├── config/ │ └── config.py └── tests/ ├── base_test.py ├── test_login.py └── test_dashboard.py폴더별로 역할이 명확했다. 새 기능 테스트 짤 때 어디서 가져올지 바로 알았다. 로그인 기능? pages/login_page.py. 헤더 검색? components/header.py. 유저 생성? api/user_api.py. import도 일관적이었다. from pages.login_page import LoginPage from components.header import Header from api.user_api import UserAPI from fixtures.user_factory import UserFactory from utils.waits import CustomWait문서화도 라이브러리의 일부다 코드만 있으면 안 됐다. 후배가 어떻게 쓰는지 알아야 했다. 각 모듈에 docstring을 추가했다. class LoginPage: """ 로그인 페이지 객체 사용법: login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") 메서드: - login(email, password): 로그인 수행, DashboardPage 반환 - login_with_google(): 구글 로그인, DashboardPage 반환 - forgot_password(email): 비밀번호 재설정 메일 전송 """README.md도 작성했다. # 테스트 자동화 라이브러리## 빠른 시작1. 로그인 테스트 ```python from pages.login_page import LoginPagedef test_login(driver): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()데이터 생성from fixtures.user_factory import UserFactory from api.user_api import UserAPIuser_data = UserFactory.create_user() user_api = UserAPI(BASE_URL, TOKEN) created_user = user_api.create_user(user_data)예제가 있으니 후배가 금방 따라했다. 질문도 줄었다.## 버전 관리도 했다라이브러리가 바뀌면 알려줘야 했다. 어떤 기능이 추가됐고, 뭐가 deprecated 됐는지.CHANGELOG.md를 만들었다.```markdown # Changelog## [2.1.0] - 2024-01-15 ### Added - Modal 컴포넌트에 `wait_until_closed()` 메서드 추가 - ProductFactory에 `create_bundle()` 메서드 추가### Changed - CustomWait의 기본 timeout을 10초에서 15초로 변경### Deprecated - `login_page.login_old()` 메서드는 v3.0.0에서 제거 예정## [2.0.0] - 2023-12-01 ### Breaking Changes - LoginPage의 `login()` 메서드가 DashboardPage 대신 HomePage 반환팀원들이 업데이트 전에 체크했다. Breaking change가 있으면 테스트 코드를 미리 수정했다. 실제로 얼마나 빨라졌나 숫자로 측정했다. 라이브러리 도입 전과 후. 새 기능 테스트 작성 시간전: 평균 2주 (10개 시나리오 기준) 후: 평균 3일테스트 유지보수 시간전: UI 변경 시 평균 5일 (영향받는 테스트 수정) 후: 평균 1일 (페이지 객체만 수정)코드 재사용률전: 30% (복붙이 대부분) 후: 80% (라이브러리 사용)테스트 코드 길이전: 평균 100줄 후: 평균 30줄수치가 명확하니 설득력이 있었다. PM도 인정했다. "자동화 투자가 빛을 보네요." 팀원들도 쓰기 시작했다 처음엔 나만 썼다. 후배는 여전히 매번 새로 짰다. 어느 날 후배가 물었다. "선배, 로그인 함수 어디 있어요?" "pages 폴더에 LoginPage 있어." "아 이거... 이거 쓰면 되는구나." 다음 날 후배 코드를 봤다. LoginPage, UserFactory, CustomWait를 썼다. 코드가 내 스타일과 비슷했다. 일주일 후 후배가 말했다. "선배, 저도 컴포넌트 하나 만들어도 돼요? Notification 자주 써서요." "당연하지. PR 올려봐." # components/notification.py class Notification: def __init__(self, driver): self.driver = driver self.toast = (By.CLASS_NAME, "toast-message") def get_message(self): element = self.driver.find_element(*self.toast) return element.text def wait_until_disappear(self): wait = WebDriverWait(self.driver, 10) wait.until(EC.invisibility_of_element_located(self.toast))좋았다. 머지했다. 이제 팀 전체가 Notification 컴포넌트를 쓴다. 라이브러리가 함께 자랐다. 혼자 만들 때보다 빨랐다. 신입이 와도 빨리 적응했다 3개월 전 신입이 들어왔다. QA 경험 1년, 자동화는 처음. "뭐부터 배워야 해요?" README 보여줬다. "여기 예제 따라해봐. 로그인 테스트부터." 2시간 뒤 신입이 로그인 테스트를 짰다. LoginPage 쓰고, UserFactory 쓰고, CustomAssertions 썼다. "이거 다 있네요. 편하다." 1주일 뒤 신입이 검색 기능 테스트를 완성했다. 혼자서. 예전 같았으면 한 달 걸렸을 거다. 라이브러리가 온보딩 시간을 줄였다. 한계도 있었다 라이브러리가 만능은 아니었다. 과도한 추상화 너무 범용적으로 만들

Appium으로 모바일 자동화 시작하기: 안드로이드와 iOS의 차이

Appium으로 모바일 자동화 시작하기: 안드로이드와 iOS의 차이

Appium 시작하고 3일 만에 깨달은 것 Appium 공부 시작했다. 회사에서 모바일 앱 자동화하라고 했다. 첫날은 설치만 했다. 두 번째 날은 에러만 봤다. 셋째 날 실행됐는데 안드로이드만 됐다. iOS는 또 다른 세계더라. 크로스 플랫폼이라고 했다. 같은 코드로 두 플랫폼 테스트 가능하다고 했다. 반은 맞다. 반은 거짓말이다. 기본 개념은 같다. 세부 구현은 완전히 다르다. 지금 Appium 3개월 썼다. 안드로이드와 iOS 둘 다 돌린다. 아직도 매일 새로운 이슈 만난다. 이게 모바일 자동화의 현실이다.안드로이드 시작: 상대적으로 쉬운 편 안드로이드부터 했다. 설정이 iOS보다 직관적이다. Android Studio 깔고, SDK 설치하고, AVD 만들면 된다. 에뮬레이터 띄우는 데 10분 걸렸다. UIAutomator2가 기본이다. 요소 찾기가 편하다. resource-id, content-desc, text로 찾으면 대부분 된다. driver.find_element(AppiumBy.ID, "com.app:id/button") 이렇게. 앱 빌드도 간단하다. APK 파일 하나면 끝이다. 개발자한테 디버그 APK 받아서 adb install app.apk 치면 설치된다. 권한 문제도 별로 없다. 실제 코드 짜보니까 금방 돌았다. driver.find_element(AppiumBy.ID, "username").send_keys("test") driver.find_element(AppiumBy.ID, "password").send_keys("1234") driver.find_element(AppiumBy.ID, "login_button").click()이거 5분 만에 작동했다. 문제는 버전이다. 안드로이드 파편화가 심하다. API 28, 29, 30, 31 다 테스트해야 한다. 에뮬레이터 5개 만들었다. 디스크 용량 50GB 날아갔다. 스크롤도 플랫폼마다 다르다. 새로운 UiScrollable 쓰면 되는데 문법이 특이하다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector()).scrollIntoView(text("원하는텍스트"))') 처음엔 이게 뭔가 싶었다. 그래도 안드로이드는 괜찮다. 디버깅이 쉽다. Appium Inspector로 요소 바로 확인된다. xpath 복잡해도 일단 동작한다.iOS 진입: 벽이 높다 iOS 시작했다. 막혔다. 설정부터 복잡하다. Xcode 깔고, Command Line Tools 설정하고, WebDriverAgent 빌드해야 한다. 첫날 4시간 걸렸다. 제대로 안 됐다. Provisioning Profile이 문제였다. 서명 이슈다. 무료 Apple ID로는 7일마다 재빌드해야 한다. 회사 계정 쓰면 1년인데 설정이 어렵다. 시뮬레이터는 빠르다. 그런데 Mac만 된다. 윈도우 개발자는 테스트 못 돌린다. 우리 팀 절반이 윈도우 쓴다. 문제다. XCUITest가 기본 드라이버다. 요소 찾기가 다르다. 안드로이드처럼 ID 없다. accessibility identifier 써야 한다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton") 개발자가 accessibility 안 넣으면 못 찾는다. 처음엔 xpath만 썼다. //XCUIElementTypeButton[@name="Login"] 느리고 불안정하다. 개발자한테 요청했다. 버튼, 텍스트 필드 타입이 XCUIElement로 시작한다. XCUIElementTypeButton, XCUIElementTypeTextField 이런 식이다. 안드로이드랑 완전히 다르다. 코드 재사용 안 된다. 실기기는 더 복잡하다. UDID 등록해야 한다. idevice_id -l 로 확인하고 capabilities에 넣는다. 케이블로 연결해야 한다. 무선은 불안정하다. 앱 설치도 까다롭다. .app 파일이 시뮬레이터용이다. 실기기는 .ipa 파일 필요하다. 서명도 맞아야 한다. 개발자한테 Ad-hoc 빌드 받아야 한다. iOS 13 이후로 권한 팝업이 많다. "Allow 'App' to access your location?" 이런 거. 자동으로 처리 안 된다. 직접 클릭 코드 짜야 한다. try: alert = driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Allow']") alert.click() except: pass예외 처리 안 하면 테스트 멈춘다. 한 달 걸렸다. iOS 자동화 제대로 돌리는 데. 안드로이드의 3배 시간 들었다. 아직도 가끔 서명 만료로 깨진다. 플랫폼별 Desired Capabilities: 설정이 다르다 안드로이드 capabilities는 간단하다. android_caps = { "platformName": "Android", "platformVersion": "12", "deviceName": "Pixel_5_API_31", "app": "/path/to/app.apk", "automationName": "UiAutomator2", "appPackage": "com.example.app", "appActivity": ".MainActivity" }appPackage랑 appActivity 넣으면 된다. APK 안 넣고 이미 설치된 앱 실행 가능하다. adb shell pm list packages 로 패키지명 확인한다. iOS는 복잡하다. ios_caps = { "platformName": "iOS", "platformVersion": "16.0", "deviceName": "iPhone 14", "app": "/path/to/app.app", "automationName": "XCUITest", "bundleId": "com.example.app", "xcodeOrgId": "TEAMID", "xcodeSigningId": "iPhone Developer", "udid": "device-udid-here" }실기기는 UDID 필수다. xcodeOrgId도 넣어야 한다. 서명 관련 설정 빠뜨리면 에러 난다. 처음엔 뭐가 필요한지 몰라서 하나씩 추가했다. autoAcceptAlerts 옵션이 있다. 권한 팝업 자동 승인한다. "autoAcceptAlerts": True근데 완벽하지 않다. 커스텀 팝업은 직접 처리해야 한다. noReset 옵션도 중요하다. "noReset": TrueTrue면 앱 상태 유지한다. 로그인 풀리지 않는다. False면 매번 초기화한다. 깨끗하지만 느리다. 테스트 목적에 따라 바꾼다. 로그인 테스트는 noReset False. 로그인 후 기능 테스트는 True. 두 플랫폼 동시에 돌리려면 코드 분기해야 한다. if platform == "android": caps = android_caps else: caps = ios_caps driver = webdriver.Remote("http://localhost:4723", caps)처음엔 하나로 합치려 했다. 안 됐다. 공통 부분만 베이스로 두고 나머지 분리했다. 유지보수가 더 쉬웠다.에뮬레이터 vs 실기기: 무엇을 선택할까 에뮬레이터부터 시작했다. 설정이 쉽다. Android Studio AVD Manager로 몇 번 클릭하면 만들어진다. Xcode 시뮬레이터도 바로 뜬다. 속도가 빠르다. 테스트 실행이 실기기보다 2배 빠르다. CI/CD 파이프라인에 넣기 좋다. Jenkins에서 돌리는데 문제없다. 비용이 없다. 무제한으로 만들 수 있다. 안드로이드 에뮬레이터 5개 띄워서 병렬 테스트 돌린다. 실기기면 불가능하다. 하지만 한계가 있다. 실제 사용자 환경이 아니다. 카메라, GPS, 블루투스 테스트 안 된다. 하드웨어 의존 기능은 실기기 필수다. 성능도 다르다. 에뮬레이터가 너무 빠르다. 실기기에서는 느린 애니메이션 기다려야 하는데 에뮬레이터는 바로 넘어간다. 타이밍 이슈 생긴다. 네트워크도 차이 난다. 에뮬레이터는 호스트 네트워크 쓴다. 실기기는 WiFi나 LTE다. 속도 다르다. 네트워크 지연 테스트는 실기기로 해야 한다. 우리 팀 전략은 이렇다.개발 중: 에뮬레이터로 빠르게 테스트 PR 머지 전: 실기기 1대로 스모크 테스트 릴리즈 전: 실기기 여러 대로 풀 테스트실기기 관리가 일이다. 충전 신경 써야 한다. 케이블 빠지면 테스트 멈춘다. 회사 돈 들여서 기기 팜 만들었다. 안드로이드 실기기 3대, iOS 2대 있다. 삼성, LG, 픽셀 / 아이폰 12, 13 주요 버전 커버한다. USB 허브로 다 연결했다. 동시 테스트 가능하다. Appium Grid 쓰면 병렬로 돌린다. 5대 동시 실행하면 30분 → 6분 걸린다. 기기 이름 라벨 붙였다. 헷갈린다. "Android_Samsung_S21", "iOS_iPhone13" 코드에서 deviceName으로 지정한다. 처음엔 실기기만 고집했다. 시간 오래 걸렸다. 지금은 적절히 섞는다. 상황에 맞게. 완벽한 정답은 없다. 요소 찾기 전략: ID vs Accessibility vs XPath 안드로이드는 resource-id가 최고다. driver.find_element(AppiumBy.ID, "com.app:id/login_button")빠르고 안정적이다. 화면 구조 바뀌어도 동작한다. 개발자한테 고유 ID 달라고 한다. 처음엔 안 넣어줬다. 설득했다. "자동화 안 되면 수동으로 계속 테스트해야 합니다" 그랬더니 넣어줬다. text로 찾는 건 위험하다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'text("로그인")')텍스트 바뀌면 깨진다. 다국어 지원하면 더 문제다. 영어 버전 테스트는 "Login"으로 바꿔야 한다. content-desc도 좋다. 접근성 레이블이다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_button")안드로이드와 iOS 둘 다 쓸 수 있다. 크로스 플랫폼 코드 작성할 때 유용하다. iOS는 accessibility identifier가 표준이다. driver.find_element(AppiumBy.ACCESSIBILITY_ID, "LoginButton")개발자가 accessibilityIdentifier 속성 넣어줘야 한다. Swift 코드로 이렇게 넣는다고 한다. button.accessibilityIdentifier = "LoginButton"없으면 XPath 써야 한다. XPath는 최후의 수단이다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='로그인']")느리다. 화면 구조 바뀌면 바로 깨진다. 3초 걸리던 게 XPath 쓰면 10초 걸린다. 복잡한 XPath는 더 나쁘다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeOther[2]/XCUIElementTypeOther[1]/XCUIElementTypeButton[3]")이건 유지보수 불가능하다. 절대 쓰지 말 것. Class name으로도 찾는다. driver.find_element(AppiumBy.CLASS_NAME, "android.widget.Button")여러 개 나온다. 인덱스로 접근해야 한다. buttons = driver.find_elements(AppiumBy.CLASS_NAME, "android.widget.Button") buttons[2].click()위험하다. 버튼 순서 바뀌면 끝이다. 우리 팀 규칙은 이렇다.먼저 ID 또는 accessibility identifier 확인 없으면 개발자한테 요청 급하면 임시로 XPath, 나중에 리팩토링 text는 상수로 관리페이지 오브젝트 패턴 쓴다. class LoginPage: USERNAME = (AppiumBy.ID, "username") PASSWORD = (AppiumBy.ID, "password") LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "login_button") def login(self, user, pw): self.driver.find_element(*self.USERNAME).send_keys(user) self.driver.find_element(*self.PASSWORD).send_keys(pw) self.driver.find_element(*self.LOGIN_BTN).click()로케이터 한 곳에 모았다. 변경 쉽다. 테스트 코드는 깔끔하다. login_page = LoginPage(driver) login_page.login("test", "1234")처음엔 테스트 코드에 직접 썼다. 지옥이었다. 화면 하나 바뀌면 10개 파일 수정했다. 리팩토링하는 데 2주 걸렸다. 후회했다. 플랫폼별 제스처: 스와이프, 탭, 스크롤 터치 제스처가 핵심이다. 모바일은 마우스 없다. 안드로이드와 iOS 구현 방법이 다르다. 안드로이드 스와이프는 TouchAction 쓴다. from appium.webdriver.common.touch_action import TouchActionaction = TouchAction(driver) action.press(x=500, y=1000).wait(1000).move_to(x=500, y=300).release().perform()좌표 기반이다. 화면 크기마다 다르다. 상대 좌표로 바꿨다. size = driver.get_window_size() start_x = size['width'] * 0.5 start_y = size['height'] * 0.8 end_y = size['height'] * 0.2action.press(x=start_x, y=start_y).wait(1000).move_to(x=start_x, y=end_y).release().perform()모든 기기에서 동작한다. iOS는 좀 다르다. W3C Actions API 쓴다. driver.execute_script("mobile: swipe", {"direction": "up"})간단하다. 하지만 커스터마이징 어렵다. 정교한 스와이프는 여전히 좌표 써야 한다. from selenium.webdriver.common.actions import interaction from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions.pointer_input import PointerInputactions = ActionBuilder(driver, mouse=PointerInput(interaction.POINTER_TOUCH, "touch")) actions.pointer_action.move_to_location(500, 1000) actions.pointer_action.pointer_down() actions.pointer_action.pause(1) actions.pointer_action.move_to_location(500, 300) actions.pointer_action.release() actions.perform()복잡하다. 처음엔 이해 안 됐다. 스크롤은 더 까다롭다. 안드로이드는 UiScrollable 쓴다. driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(text("찾을텍스트"))')텍스트 찾을 때까지 스크롤한다. iOS는 그런 게 없다. 직접 구현해야 한다. def scroll_to_element(driver, element_id): max_swipes = 10 for i in range(max_swipes): try: element = driver.find_element(AppiumBy.ACCESSIBILITY_ID, element_id) return element except: size = driver.get_window_size() driver.execute_script("mobile: scroll", {"direction": "down"}) raise Exception(f"Element {element_id} not found after {max_swipes} swipes")무한 스크롤은 더 어렵다. 이전 요소 저장하고 같으면 멈춘다. prev_page_source = "" while True: current_page_source = driver.page_source if prev_page_source == current_page_source: break # 스크롤 로직 prev_page_source = current_page_source탭과 클릭은 거의 같다. element.click()가끔 안 될 때 있다. 좌표로 직접 탭한다. location = element.location size = element.size x = location['x'] + size['width'] / 2 y = location['y'] + size['height'] / 2action = TouchAction(driver) action.tap(x=x, y=y).perform()더블 탭은 빠르게 두 번 탭한다. action.tap(x=x, y=y).wait(100).tap(x=x, y=y).perform()롱 프레스는 press().wait().release()다. action.press(x=x, y=y).wait(2000).release().perform()2초 누른다. 컨텍스트 메뉴 나온다. 핀치 줌은 복잡하다. 두 손가락 시뮬레이션이다. 거의 안 쓴다. 필요하면 개발자한테 버튼 만들라고 한다. 제스처 코드는 헬퍼 함수로 만들었다. class GestureHelper: @staticmethod def swipe_up(driver): # 구현 @staticmethod def swipe_down(driver): # 구현 @staticmethod def scroll_to_text(driver, text): # 구현테스트에서 이렇게 쓴다. GestureHelper.swipe_up(driver) GestureHelper.scroll_to_text(driver, "설정")깔끔하다. 재사용 쉽다. 크로스 플랫폼 코드 작성: 공통과 분기 처음엔 한 코드로 두 플랫폼 테스트하려 했다. 불가능했다. 요소 찾기부터 다르다. 공통 부분만 추출했다. 테스트 로직이다. def test_login(driver): login_page = get_login_page(driver) login_page.enter_username("test") login_page.enter_password("1234") login_page.click_login() assert login_page.is_logged_in()get_login_page()가 플랫폼 구분한다. def get_login_page(driver): platform = driver.capabilities['platformName'] if platform == 'Android': return AndroidLoginPage(driver) else: return IOSLoginPage(driver)각 페이지 클래스는 같은 인터페이스 구현한다. class LoginPageBase: def enter_username(self, username): raise NotImplementedError def enter_password(self, password): raise NotImplementedError def click_login(self): raise NotImplementedError def is_logged_in(self): raise NotImplementedErrorclass AndroidLoginPage(LoginPageBase): USERNAME = (AppiumBy.ID, "com.app:id/username") PASSWORD = (AppiumBy.ID, "com.app:id/password") LOGIN_BTN = (AppiumBy.ID, "com.app:id/login") def enter_username(self, username): self.driver.find_element(*self.USERNAME).send_keys(username) # ...class IOSLoginPage(LoginPageBase): USERNAME = (AppiumBy.ACCESSIBILITY_ID, "UsernameField") PASSWORD = (AppiumBy.ACCESSIBILITY_ID, "PasswordField") LOGIN_BTN = (AppiumBy.ACCESSIBILITY_ID, "LoginButton") def enter_username(self, username): self.driver.find_element(*self.USERNAME).send_keys(username) # ...로케이터만 다르다. 메서드는 동일하다. 테스트 코드는 플랫폼 몰라도 된다. 제스처도 분기한다. class GestureHelper: @staticmethod def swipe_up(driver): platform = driver.capabilities['platformName'] if platform == 'Android': # 안드로이드 구현 else: # iOS 구현처음엔 if문 남발했다. 코드가 더러웠다. 지금은 클래스 분리했다. 깔끔하다. 설정 파일도 나눴다. # config/android.py ANDROID_CAPS = { "platformName": "Android", # ... }# config/ios.py IOS_CAPS = { "platformName": "iOS", # ... }pytest fixture로 드라이버 생성한다. @pytest.fixture def driver(request): platform = request.config.getoption("--platform") if platform == "android": caps = ANDROID_CAPS else: caps = IOS_CAPS driver = webdriver.Remote("http://localhost:4723", caps) yield driver driver.quit()명령어로 플랫폼 지정한다. pytest --platform=android pytest --platform=ios양쪽 다 돌리려면 parameterize 쓴다. @pytest.mark.parametrize("platform", ["android", "ios"]) def test_login(platform): driver = get_driver(platform) # 테스트 로직CI/CD에서는 병렬로 돌린다. jobs: android: runs-on: ubuntu-latest steps: - run: pytest --platform=android ios: runs-on: macos-latest steps: - run: pytest --platform=ios안드로이드는 우분투에서, iOS는 맥에서 돌린다. 동시에 실행된다. 시간 절반으로 줄었다. 완전한 크로스 플랫폼은 불가능하다. 70% 정도 공통화 가능하다. 나머지는 분기해야 한다. 처음부터 이렇게 설계하면 유지보수 쉽다. 실제로 겪은 문제들과 해결 키보드 문제가 제일 짜증났다. 안드로이드는 키보드 올라오면 화면 가린다. 아래쪽 버튼 못 찾는다. driver.hide_keyboard() 쓴다. element.send_keys("text") driver.hide_keyboard() driver.find_element(AppiumBy.ID, "submit").click()iOS는 키보드 내리기 어렵다. 화면 빈 곳 탭하거나 Return 키 쳐야 한다. driver.find_element(AppiumBy.XPATH, "//XCUIElementTypeButton[@name='Return']").click

Python과 Selenium으로 복잡한 테스트 로직을 우아하게 작성하기

Python과 Selenium으로 복잡한 테스트 로직을 우아하게 작성하기

Python과 Selenium으로 복잡한 테스트 로직을 우아하게 작성하기 코드 리뷰에서 개발자처럼 대우받았다 오늘 코드 리뷰에서 시니어 개발자가 내 PR에 "Good design"이라고 댓글 달았다. QA가 쓴 코드에. 이게 얼마만인가. 전에는 달랐다. "이거 왜 이렇게 복잡해요?", "테스트 코드인데 이렇게까지 할 필요 있어요?" 같은 소리 들었다. 그때는 억울했다. 테스트 코드도 코드다. 유지보수한다. 확장한다. 왜 QA 코드는 날림으로 써도 된다고 생각하는 걸까. 4년 전 자동화 시작할 때는 나도 그냥 돌아가기만 하면 됐다. Selenium으로 클릭하고, 텍스트 입력하고, assert만 썼다. 그런데 테스트 케이스가 100개 넘어가니까 문제가 보였다. 중복 코드. 하드코딩된 셀렉터. 어디서 실패했는지 알 수 없는 에러 메시지. 지금은 다르다. 타입 힌팅 쓴다. 데코레이터로 반복 로직 줄인다. 컨텍스트 매니저로 리소스 관리한다. 코드가 깔끔해졌다. 버그도 줄었다. 개발자들이 내 코드를 참고한다.타입 힌팅이 바꾼 것들 타입 힌팅 쓰기 전의 코드. def login(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() return driver.find_element(By.CLASS_NAME, "welcome")이게 뭐가 문제냐고? 3개월 후 보면 모른다. username이 문자열인지 리스트인지. password는? 리턴 값은 WebElement인지 텍스트인지. 타입 힌팅 추가하면 달라진다. from typing import Optional from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.chrome.webdriver import WebDriverdef login( driver: WebDriver, username: str, password: str, timeout: int = 10 ) -> Optional[WebElement]: """로그인 수행 후 환영 메시지 요소 반환""" 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() try: welcome_element = WebDriverWait(driver, timeout).until( EC.presence_of_element_located((By.CLASS_NAME, "welcome")) ) return welcome_element except TimeoutException: return None코드가 길어졌다? 맞다. 하지만 명확하다. IDE가 자동완성 해준다. mypy로 타입 체크한다. 실수가 줄어든다. 진짜 효과는 따로 있다. 개발자들이 내 코드를 신뢰한다. "타입 힌팅까지 쓰네요?" 하면서 코드 리뷰를 제대로 한다. QA 코드니까 대충 보는 게 아니라.실전에서 더 유용한 건 복잡한 데이터 구조다. from dataclasses import dataclass from typing import List, Dict, Optional@dataclass class TestUser: """테스트용 사용자 데이터""" username: str password: str email: str role: str permissions: List[str]@dataclass class LoginResult: """로그인 결과""" success: bool user_element: Optional[WebElement] error_message: Optional[str] redirect_url: Optional[str]def perform_login( driver: WebDriver, user: TestUser ) -> LoginResult: """사용자 정보로 로그인 수행""" # 로그인 로직 pass이제 함수 시그니처만 봐도 안다. TestUser 객체 넣으면 LoginResult 나온다. 어떤 필드가 있는지. Optional인지 아닌지. 팀 후배가 물었다. "이거 배우는 데 시간 오래 걸려요?" 아니다. 타입 힌팅 기본은 하루면 된다. 문제는 습관이다. 귀찮다고 안 쓰면 끝이다. 나도 처음엔 귀찮았다. 지금은? 타입 힌팅 없는 코드 보면 불안하다. 데코레이터로 중복 제거하기 자동화 코드에는 반복이 많다. 스크린샷 찍기. 로그 남기기. 재시도. 타임아웃 처리. 예전 코드는 이랬다. def test_checkout_flow(): logger.info("Starting checkout test") try: driver.get("https://shop.example.com") screenshot(driver, "checkout_start") # 테스트 로직 add_to_cart() screenshot(driver, "cart_added") proceed_to_checkout() screenshot(driver, "checkout_page") fill_shipping_info() screenshot(driver, "shipping_filled") logger.info("Checkout test passed") except Exception as e: logger.error(f"Test failed: {e}") screenshot(driver, "error") raise모든 테스트에 이런 코드가 반복된다. 스크린샷. 로그. 예외 처리. 복사 붙여넣기 지옥이다. 데코레이터를 쓰면 달라진다. from functools import wraps from typing import Callable, Any import loggingdef screenshot_on_failure(func: Callable) -> Callable: """실패 시 자동으로 스크린샷 저장""" @wraps(func) def wrapper(*args, **kwargs) -> Any: try: return func(*args, **kwargs) except Exception as e: driver = kwargs.get('driver') or args[0].driver screenshot_path = f"failure_{func.__name__}.png" driver.save_screenshot(screenshot_path) logging.error(f"Screenshot saved: {screenshot_path}") raise return wrapperdef retry(max_attempts: int = 3, delay: float = 1.0): """실패 시 재시도""" def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: last_exception = None for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: last_exception = e if attempt < max_attempts - 1: time.sleep(delay) logging.warning( f"Retry {attempt + 1}/{max_attempts} for {func.__name__}" ) raise last_exception return wrapper return decoratordef log_test_execution(func: Callable) -> Callable: """테스트 실행 시간 및 결과 로깅""" @wraps(func) def wrapper(*args, **kwargs) -> Any: start_time = time.time() logging.info(f"Starting: {func.__name__}") try: result = func(*args, **kwargs) elapsed = time.time() - start_time logging.info(f"Passed: {func.__name__} ({elapsed:.2f}s)") return result except Exception as e: elapsed = time.time() - start_time logging.error(f"Failed: {func.__name__} ({elapsed:.2f}s) - {e}") raise return wrapper이제 테스트 함수는 깔끔하다. @log_test_execution @screenshot_on_failure @retry(max_attempts=2) def test_checkout_flow(driver: WebDriver) -> None: """체크아웃 플로우 테스트""" driver.get("https://shop.example.com") add_to_cart() proceed_to_checkout() fill_shipping_info() assert "Order confirmed" in driver.page_source비즈니스 로직만 남았다. 스크린샷, 로그, 재시도는 데코레이터가 처리한다. 코드가 짧아졌다. 의도가 명확하다. 더 좋은 건 조합이다. 데코레이터를 쌓을 수 있다. 순서도 제어한다. 로그 찍고, 재시도하고, 실패하면 스크린샷. 실전 예제 하나 더. def wait_for_element(timeout: int = 10): """요소 대기 시간 설정""" def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: driver = kwargs.get('driver') or args[0].driver original_timeout = driver.implicitly_wait(0) try: driver.implicitly_wait(timeout) return func(*args, **kwargs) finally: driver.implicitly_wait(original_timeout) return wrapper return decorator@wait_for_element(timeout=20) def test_slow_loading_page(driver: WebDriver) -> None: """느린 페이지 로딩 테스트""" driver.get("https://slow.example.com") element = driver.find_element(By.ID, "slow-content") assert element.is_displayed()이 테스트만 타임아웃이 20초다. 다른 테스트는 영향 없다. 깔끔하다. 코드 리뷰에서 개발자가 물었다. "이거 어떻게 구현했어요?" 설명해줬다. functools.wraps 쓴다. 클로저 활용한다. 그 개발자가 자기 프로덕션 코드에 비슷한 패턴 썼다. 그때 느꼈다. QA 코드도 배울 게 있구나, 하고. 컨텍스트 매니저로 리소스 관리 Selenium은 리소스 관리가 중요하다. 브라우저 세션. 파일 핸들. 데이터베이스 연결. 제대로 안 닫으면 메모리 누수. CI 서버가 죽는다. 예전엔 try-finally 남발했다. def test_with_database(): conn = psycopg2.connect(DB_URL) driver = webdriver.Chrome() try: # 테스트 로직 pass finally: driver.quit() conn.close()문제는 중첩이다. 리소스가 3개, 4개 되면 코드가 지저분해진다. finally 블록이 길어진다. 실수한다. 컨텍스트 매니저를 쓰면 자동이다. from contextlib import contextmanager from typing import Generator@contextmanager def browser_session( headless: bool = False ) -> Generator[WebDriver, None, None]: """브라우저 세션 관리""" options = ChromeOptions() if headless: options.add_argument('--headless') driver = webdriver.Chrome(options=options) try: yield driver finally: driver.quit() logging.info("Browser session closed")def test_with_browser(): with browser_session(headless=True) as driver: driver.get("https://example.com") # 테스트 로직 # 여기서 자동으로 driver.quit() 호출with 블록 벗어나면 자동으로 정리된다. 예외 발생해도. 중간에 return 해도. 안전하다. 실전에서는 더 복잡한 시나리오가 많다. @contextmanager def test_environment( user_type: str = "normal" ) -> Generator[Dict[str, Any], None, None]: """테스트 환경 자동 설정 및 정리""" # Setup driver = webdriver.Chrome() db_conn = connect_to_test_db() test_user = create_test_user(user_type) env = { 'driver': driver, 'db': db_conn, 'user': test_user } try: yield env finally: # Cleanup cleanup_test_data(test_user.id) db_conn.close() driver.quit() logging.info("Test environment cleaned up")def test_user_workflow(): with test_environment(user_type="admin") as env: driver = env['driver'] user = env['user'] login(driver, user.username, user.password) navigate_to_admin_panel(driver) # 테스트 로직 # 모든 리소스 자동 정리셋업과 클린업이 한 곳에 있다. 테스트 코드는 비즈니스 로직만. 리소스 누수 걱정 없다.더 나간다. 중첩 컨텍스트 매니저. @contextmanager def performance_monitor(test_name: str) -> Generator[None, None, None]: """테스트 성능 모니터링""" start_time = time.time() start_memory = psutil.Process().memory_info().rss / 1024 / 1024 yield end_time = time.time() end_memory = psutil.Process().memory_info().rss / 1024 / 1024 elapsed = end_time - start_time memory_used = end_memory - start_memory logging.info( f"{test_name}: {elapsed:.2f}s, {memory_used:.2f}MB" )def test_heavy_operation(): with performance_monitor("heavy_test"): with browser_session() as driver: # 무거운 테스트 로직 pass # 자동으로 성능 데이터 수집컨텍스트 매니저끼리 조합된다. 성능 측정하면서 브라우저 관리한다. 코드는 여전히 깔끔하다. 클래스 기반으로도 만들 수 있다. class DatabaseTransaction: """데이터베이스 트랜잭션 관리""" def __init__(self, connection: Any): self.conn = connection self.transaction_started = False def __enter__(self) -> Any: self.conn.begin() self.transaction_started = True return self.conn def __exit__(self, exc_type, exc_val, exc_tb) -> bool: if self.transaction_started: if exc_type is None: self.conn.commit() logging.info("Transaction committed") else: self.conn.rollback() logging.warning("Transaction rolled back") return Falsedef test_with_transaction(): with DatabaseTransaction(db_connection) as conn: # 데이터 변경 작업 conn.execute("INSERT INTO users ...") # 예외 발생하면 자동 롤백@contextmanager 데코레이터가 더 간단하다. 하지만 복잡한 상태 관리는 클래스가 낫다. 후배가 물었다. "이거 꼭 써야 해요?" 아니다. 하지만 쓰면 버그가 줄어든다. 특히 리소스 관리 버그. CI에서 가끔 브라우저 세션 안 닫혀서 메모리 터지는 거. 그런 거 없어진다. 실전에서 마주친 문제들 코드가 우아해지면 다른 문제가 생긴다. 팀원들이 이해 못 한다. "이게 뭐예요?" 하면서 기존 방식 고집한다. 매뉴얼 QA 출신 후배는 특히 어려워했다. 타입 힌팅 보고 "왜 이렇게 길어요?" 했다. 데코레이터는 "마법 같아서 무섭다"고 했다. 설득하는 데 시간 걸렸다. 코드 리뷰할 때 하나씩 설명했다. 왜 이게 필요한지. 어떤 문제를 해결하는지. 작은 예제부터 보여줬다. 3개월 지나니까 달라졌다. 후배가 데코레이터 쓰기 시작했다. "이거 편하네요" 하더라. 지금은 타입 힌팅도 쓴다. 또 다른 문제. 과도한 추상화. 코드를 우아하게 만들려다가 너무 복잡해진다. 간단한 테스트에 3단계 추상화 레이어. 이건 과하다. 원칙을 정했다. 3번 이상 반복되면 추상화한다. 2번까지는 중복 놔둔다. 이게 실용적이다. 성능 문제도 있다. 데코레이터 많이 쌓으면 오버헤드 생긴다. 컨텍스트 매니저도 마찬가지. 측정했다. 테스트 하나당 0.1초 차이. 100개 테스트면 10초. 무시 못 한다. 최적화했다. 꼭 필요한 데코레이터만 쓴다. 성능 크리티컬한 테스트는 직접 작성한다. 우아함과 성능 사이에서 균형. 개발자에게 인정받는다는 것 4년 전만 해도 QA는 개발자 아래였다. 코드 리뷰? QA 코드는 안 봤다. "돌아가면 되죠" 하고 넘어갔다. 지금은 다르다. 내 PR에 개발자들이 댓글 단다. "이 패턴 괜찮은데요?", "이거 저희 코드에도 적용할게요" 같은 거. 지난주에는 백엔드 리드가 물었다. "이 데코레이터 우리도 쓸 수 있을까요?" 내가 쓴 재시도 로직. 그 사람 서비스 코드에 적용했다. 그때 느꼈다. QA도 코드로 말할 수 있다. 개발자들이 인정해준다. 단, 조건이 있다. 코드가 좋아야 한다. 좋은 코드란? 읽기 쉽다. 유지보수 편하다. 확장 가능하다. 버그 적다. 이건 프로덕션 코드나 테스트 코드나 똑같다. 타입 힌팅, 데코레이터, 컨텍스트 매니저. 이런 게 중요한 게 아니다. 코드 품질에 대한 태도가 중요하다. QA 코드니까 대충 써도 된다? 그런 생각 버려야 한다. 지금 시작하려면 처음부터 완벽하게 하려고 하지 마라. 나도 4년 걸렸다. 첫 단계. 타입 힌팅부터. 함수 파라미터와 리턴 타입만 추가해도 달라진다. 하루면 익숙해진다. 두 번째. 중복 코드 찾아라. 3번 이상 반복되는 거. 그걸 함수로 뺀다. 나중에 데코레이터로 바꿀 수 있다. 세 번째. 리소스 관리 점검. try-finally 쓰는 곳. 컨텍스트 매니저로 바꿔라. 버그 줄어든다. 도구는 나중이다. 먼저 원칙. 좋은 코드란 무엇인가. 왜 이게 중요한가. 팀에 어떤 가치를 주는가. 코드 리뷰 받아라. 개발자한테. 부끄러워하지 마라. 피드백 받고 개선해라. 처음엔 지적 많이 받는다. 괜찮다. 성장하는 거다. 컨퍼런스 발표 자료 보라. "Testing in Python" 같은 거. 실전 패턴 많다. 책도 괜찮다. "Python Testing with pytest" 읽어봐라.오늘도 코드 리뷰에서 "LGTM" 받았다. Looks Good To Me. 개발자들이 내 코드에 달아주는 댓글. 4년 전에는 상상도 못 했다.

자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까

자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까

자동화 커버리지 80% vs 100%: 그 사이 어디가 현실일까 경영진의 질문 "자동화 커버리지 몇 퍼센트예요?" CTO가 물었다. 월요일 아침 10시, 스탠드업 미팅. "현재 73%입니다." "100% 언제쯤 가능할까요?" 순간 멈칫했다. 이 질문, 매번 나온다. "현실적으로 어렵습니다." "왜요? 다른 회사는 90% 넘는다는데." 설명해야 한다. 또.자동화 커버리지. 숫자 하나로 모든 걸 판단한다. 80%면 좋은 거다. 90%면 훌륭하다. 100%면 완벽하다. 정말 그럴까. 4년 동안 자동화 했다. 처음엔 나도 100%를 꿈꿨다. 지금은 안다. 불가능하다는 걸. 80%의 벽 우리 서비스, 테스트 케이스 2,400개. 자동화된 케이스 1,752개. 정확히 73%. 남은 648개. 왜 자동화 안 했을까. 이유는 명확하다. 첫째, UI 테스트의 함정. 로그인 페이지 자동화. 쉽다. driver.find_element(By.ID, "username").send_keys("test") driver.find_element(By.ID, "password").send_keys("1234") driver.find_element(By.ID, "login-btn").click()3줄이면 끝. 그런데 메인 페이지는 다르다. 애니메이션 들어간 대시보드. 차트가 동적으로 그려진다. 무한 스크롤. 레이지 로딩. 실시간 업데이트. 어떻게 자동화하나. # 차트 로딩 대기 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS, "chart-loaded")) )# 아니다, 10초론 부족하다 # 15초? 20초? # 네트워크 느리면 30초도 모자라다대기 시간을 늘리면 테스트가 느려진다. 줄이면 Flaky Test가 된다. 실패했다가 다시 돌리면 성공한다. 이게 버그인가 타이밍 문제인가.둘째, 써드파티 API. 결제 시스템. PG사 API 쓴다. 테스트 서버 있다. 좋다. 그런데 불안정하다. def test_payment(): response = requests.post( "https://testpay.pg.com/api/v1/payment", json={"amount": 10000} ) assert response.status_code == 200오전엔 성공한다. 오후엔 타임아웃. 저녁엔 502 Bad Gateway. 우리 코드 문제가 아니다. PG사 테스트 서버가 그렇다. 이걸 자동화에 넣으면 CI가 계속 깨진다. 개발자들이 묻는다. "이거 우리 버그예요?" "아니요, PG사 서버 문제예요." "그럼 테스트를 왜 돌려요?" 할 말이 없다. 결국 Mocking한다. @mock.patch('requests.post') def test_payment(mock_post): mock_post.return_value.status_code = 200 # 테스트 진행이게 진짜 테스트일까. 실제 PG사 API는 안 건드린다. 우리가 만든 가짜 응답만 테스트한다. 커버리지는 올라간다. 100개 케이스 추가. 실제 품질은? 모른다. 73%의 의미 우리 자동화 커버리지 73%. 이게 낮은 걸까. 계산해봤다. E2E 테스트: 120개 (자동화 48개, 40%) 풀 사용자 플로우. 회원가입부터 결제까지. 시나리오가 길다. 15분짜리도 있다. 중간에 하나만 깨져도 전체가 실패. 네트워크, 써드파티, 브라우저 버전, OS 차이. 변수가 너무 많다. 40% 자동화도 선방한 거다. 통합 테스트: 680개 (자동화 612개, 90%) API 테스트. 이건 쉽다. def test_user_api(): response = client.get("/api/users/1") assert response.status_code == 200 assert response.json()["name"] == "테스트"서버만 있으면 된다. UI 없다. 브라우저 없다. 빠르다. 안정적이다. 90% 자동화. 만족스럽다. 단위 테스트: 1,600개 (자동화 1,092개, 68%) 함수 단위 테스트. 개발자가 짜야 하는데 안 짠다. "QA가 테스트하잖아요." 이런 케이스는 우리가 자동화해도 의미 없다. 코드 레벨 테스트는 개발자 몫이다.전체 73%. E2E 40%, 통합 90%, 단위 68%. 피라미드 구조다. 정상이다. 그런데 경영진은 하나의 숫자만 본다. "73%요. 낮네요." 100%의 환상 전 직장 동료 H. 대기업 QA였다. 자동화 커버리지 95% 달성했다고 자랑했다. "어떻게 했어?" "UI 테스트 다 넣었지." "Flaky Test는?" "많아. 그냥 3번 돌려서 1번 성공하면 pass 처리." 이게 95%의 실체다. 불안정한 테스트를 숫자에 포함시킨다. 의미 없는 테스트도 넣는다. def test_homepage_loads(): driver.get("https://example.com") assert "Example" in driver.title이런 테스트. 10초 걸린다. 확인하는 건 타이틀뿐. 이게 1개 케이스로 카운트된다. 커버리지는 올라간다. 실효성은 낮다. 또 다른 회사 K. 자동화 커버리지 100% 달성. 비결이 뭘까. 테스트 케이스를 줄였다. 2,000개 → 800개. 자동화 어려운 케이스는 아예 삭제. "이건 매뉴얼로 하면 돼요." 남은 800개만 자동화. 800/800, 100%. 숫자는 예쁘다. 실제 커버리지는? 40%. 현실적인 목표 그래서 내 결론. 80%가 현실이다. 100%는 환상이다. 자동화 가능한 것: 80% 불가능한 것: 20% 20%는 이렇다.복잡한 UI 인터랙션드래그 앤 드롭 멀티터치 제스처 캔버스 기반 에디터 실시간 화상 채팅불안정한 외부 의존성써드파티 API 테스트 서버 결제 시스템 샌드박스 SNS 로그인 연동 이메일/SMS 발송 확인시각적 검증디자인 QA 애니메이션 자연스러움 반응형 레이아웃 미세 조정 다국어 텍스트 overflow탐색적 테스트예상 못 한 버그 찾기 사용자 관점 UX 검증 경계값 실험 직관적 이상함 감지이 20%는 사람이 한다. 비효율적이지 않다. 필수다. 80%를 지키는 법 그럼 80%는 어떻게 달성할까. 내 원칙 5가지. 1. 테스트 피라미드 따르기 E2E: 최소한만 (10%) 통합: 핵심 기능 (40%) 단위: 최대한 많이 (50%) E2E 많이 만들면 커버리지 빨리 오른다. 하지만 느리고 불안정하다. 단위 테스트가 기반이어야 한다. 2. Flaky Test 절대 용납 안 함 테스트가 불안정하면 즉시 수정하거나 삭제. "가끔 실패해요"는 없다. 실패하는 테스트는 신뢰를 깎는다. CI가 빨간색이면 모두가 무시한다. 3. Mocking의 한계 인정 Mocking은 도구다. 만능이 아니다. 외부 API는 Mock한다. 하지만 통합 테스트는 따로 둔다. 주 1회, 실제 테스트 서버 대상으로 돌린다. 느려도 괜찮다. 정확도가 중요하다. 4. ROI 계산하기 이 테스트 자동화하는 시간: 3시간 매뉴얼로 하는 시간: 5분 실행 빈도: 월 1회 3시간 투자해서 월 5분 절약. 의미 없다. 자주 실행하는 것만 자동화한다. CI에 들어갈 테스트, 회귀 테스트, 스모크 테스트. 5. 유지보수 고려 자동화 코드도 코드다. 관리해야 한다. UI 바뀌면 스크립트도 바뀐다. Page Object Pattern 쓴다. class LoginPage: def __init__(self, driver): self.driver = driver def enter_username(self, username): self.driver.find_element(By.ID, "username").send_keys(username) def enter_password(self, password): self.driver.find_element(By.ID, "password").send_keys(password)UI 변경되면 이 클래스만 수정. 테스트 코드 100개 건드릴 필요 없다. 경영진 설득하기 다시 CTO에게 설명했다. "100% 불가능한 이유 3가지 말씀드리겠습니다." 회의실 화이트보드. 피라미드 그렸다. "첫째, 테스트 피라미드 구조상 E2E는 소수여야 합니다. 우리는 40% 자동화했고, 이는 업계 평균 이상입니다." "둘째, 써드파티 의존성. 결제, 인증, 알림 시스템. 이들의 테스트 환경은 불안정합니다. Mock으로 대체 가능하지만, 실제 커버리지는 낮아집니다." "셋째, 시각적 요소와 탐색적 테스트. 이는 자동화로 대체 불가능합니다. 사람의 눈과 직관이 필요합니다." CTO가 고개를 끄덕였다. "그럼 현재 73%는?" "안정적으로 유지 가능한 최대치입니다. 80%까지는 올릴 수 있지만, 3개월 소요됩니다. 우선순위 조정 필요합니다." "신규 기능 개발이 우선인가요, 자동화 커버리지 증가가 우선인가요?" 침묵. "신규 기능이 우선입니다." "그렇다면 73% 유지하면서, 신규 기능에 대한 자동화는 즉시 추가하겠습니다. 분기마다 5%씩 증가 목표로 하겠습니다." 현실적인 제안. 승인됐다. 숫자의 함정 자동화 커버리지는 숫자다. 숫자는 거짓말을 한다. 95%인데 Flaky Test 투성이인 팀. 70%인데 모든 테스트가 안정적인 팀. 어느 쪽이 나을까. 당연히 후자다. 커버리지 높이기는 쉽다. 의미 없는 테스트 추가하면 된다. 케이스 줄이면 된다. Mock 남발하면 된다. 하지만 품질은 안 올라간다. 진짜 중요한 건 이거다. "이 자동화가 버그를 잡아낼 수 있나?" 73%의 테스트가 버그 100개 찾는다. 95%의 테스트가 버그 50개 찾는다. 나는 73%를 선택한다. 3개월 후 현재 커버리지: 77%. 4% 올렸다. 추가한 테스트: 96개. 모두 통합 테스트. API 위주. 안정적이다. CI에서 한 번도 안 깨졌다. E2E는 2개만 추가. 핵심 시나리오만. 회원가입-구매-환불. UI 변경 적은 부분만 골랐다. CTO가 물었다. "80% 언제 되나요?" "다음 분기요." "100%는?" "현실적으로 어렵습니다. 대신 77% 테스트가 모두 의미 있습니다. 한 번도 False Positive 없었습니다." 고개를 끄덕인다. "좋습니다. 품질이 더 중요하죠." 드디어 이해했다. 자동화의 본질 자동화는 수단이다. 목적이 아니다. 목적은 품질이다. 100% 자동화해도 버그 못 잡으면 무용지물. 50% 자동화해도 치명적 버그 잡으면 성공. 숫자에 집착하지 않는다. 대신 이것만 지킨다.모든 자동화 테스트는 안정적이어야 한다. 실패는 항상 실제 버그여야 한다. 빠르게 실행되어야 한다. 유지보수가 쉬워야 한다. 비용 대비 효과가 있어야 한다.이 5가지를 지키는 게 80%다. 100%는 욕심이다.오늘도 CI 빌드 통과. 77% 자동화, 0% 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]