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. 이게 진짜 성공이다.

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시 슬랙. 이제 덜 무섭다. 체크리스트 있으니까.

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까

개발자가 UI 한 줄 바꿀 때마다 내 스크립트는 왜 폭탄이 될까 새벽 2시의 슬랙 알람 새벽 2시에 핸드폰이 울렸다. Jenkins 알람. 또 깨졌다. "Nightly build failed. 67 tests broken." 눈을 비비고 노트북을 켰다. 테스트 리포트를 열었다. 로그인 버튼을 못 찾는다고 한다. 전부. "ElementNotInteractableException: element not found: #login-btn" 어제 저녁까지 멀쩡했다. 67개 테스트가 동시에 깨질 리 없다. 무언가 바뀐 거다. 개발팀 슬랙을 확인했다. 밤 11시에 프론트엔드 개발자가 올린 커밋. "refactor: button ID 네이밍 컨벤션 변경 (#login-btn → #btn-login)" 이 한 줄. 이 한 줄 때문에 67개. 다시 잤다. 아침에 고치기로 했다.출근해서 본 현실 9시 50분 출근. 커피부터. 테스트 결과를 다시 봤다. 67개가 아니었다. 89개. 로그인 버튼만 바뀐 게 아니었다. 메뉴 버튼, 검색창, 확인 버튼. ID가 전부 바뀌었다. 프론트 개발자한테 물었다. "형, ID 바꿨어?" "응. 네이밍 규칙 통일했어. 코드 리뷰에서 지적받아서." "QA팀한테 얘기 없었는데." "아 미안. 그냥 ID만 바꾼 거라." 그냥 ID만. 그 말이 제일 무섭다. 테스트 스크립트 89개를 열었다. find_element_by_id를 찾았다. 247개. 하나씩 고쳐야 한다.왜 매번 깨지는가 이게 처음이 아니다. 지난달에는 class name이 바뀌었다. "btn-primary"가 "primary-button"이 됐다. 스크립트 52개 수정. 그 전 달에는 div 구조가 바뀌었다. XPath가 전부 틀어졌다. 스크립트 38개 수정. 매달 이런다. 개발자는 UI를 개선한다. 나는 스크립트를 고친다. 왜 이렇게 취약한가. 로케이터 전략이 문제다. 나는 ID로 찾고, class로 찾고, XPath로 찾는다. 개발자는 그걸 바꾼다. 끝. Selenium 코드를 봤다. driver.find_element_by_id("login-btn").click() driver.find_element_by_class_name("btn-primary").click() driver.find_element_by_xpath("//div[@class='container']/button[1]").click()이게 89개 파일에 흩어져 있다. 하나 바뀌면 전부 바꿔야 한다. 문제는 결합도다. 테스트 코드가 UI 구현에 직접 의존한다. UI가 바뀌면 테스트가 깨진다. 필연이다. 해결책은 간단하다. 추상화.페이지 객체 모델이 답이다 POM. Page Object Model. 들어는 봤다. 써본 적은 없었다. 개념은 단순하다. UI를 클래스로 감싼다. 테스트는 클래스를 쓴다. UI가 바뀌면 클래스만 고친다. LoginPage 클래스를 만들었다. class LoginPage: def __init__(self, driver): self.driver = driver self.login_button = (By.ID, "login-btn") self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") def click_login(self): self.driver.find_element(*self.login_button).click() def enter_username(self, username): self.driver.find_element(*self.username_input).send_keys(username)테스트 코드가 바뀌었다. # 전 driver.find_element_by_id("username").send_keys("test@test.com") driver.find_element_by_id("password").send_keys("password123") driver.find_element_by_id("login-btn").click()# 후 login_page = LoginPage(driver) login_page.enter_username("test@test.com") login_page.enter_password("password123") login_page.click_login()ID가 바뀌면 어떻게 되나. LoginPage만 고친다. 한 군데. 89개 테스트를 LoginPage를 쓰게 바꿨다. 이틀 걸렸다. 다음 날 ID가 또 바뀌었다. "#btn-login"이 "#submit-login"이 됐다. LoginPage 한 줄만 고쳤다. 30초. 효과는 즉시 나타났다. 로케이터 전략의 우선순위 POM을 쓰기 시작하면서 로케이터 전략을 다시 생각했다. 무엇으로 요소를 찾을 것인가. ID? Class? XPath? 우선순위를 정했다. 1순위: data 속성 가장 안정적이다. UI용이 아니라 테스트용이다. 개발자한테 부탁했다. "테스트용 속성 좀 넣어줘." <button id="btn-login" data-testid="login-button">로그인</button>data-testid는 바뀔 이유가 없다. UI 디자인과 무관하다. 테스트만을 위한 속성이다. self.login_button = (By.CSS_SELECTOR, "[data-testid='login-button']")처음엔 개발자가 귀찮아했다. "매번 넣어야 돼?" 설득했다. "한 번만 넣으면 됩니다. 안 바뀌잖아요." 점점 늘었다. 지금은 주요 버튼에 다 들어간다. 2순위: ID ID는 유일하다. 빠르다. 하지만 바뀐다. 프론트 개발자가 네이밍 규칙을 바꾸면 ID가 바뀐다. 리팩토링하면 ID가 바뀐다. ID를 쓸 땐 POM 안에만 쓴다. 바뀌면 POM만 고친다. 3순위: CSS Selector class는 자주 바뀐다. 디자인 시스템이 바뀌면 class가 바뀐다. 대신 구조로 찾는다. self.submit_button = (By.CSS_SELECTOR, "form.login-form button[type='submit']")form 안의 submit 버튼. 구조는 잘 안 바뀐다. 꼴찌: XPath XPath는 최후의 수단이다. 취약하다. 느리다. # 나쁜 예 driver.find_element_by_xpath("//div[@class='container']/div[2]/button[1]")div 하나만 추가돼도 깨진다. 순서가 바뀌면 깨진다. XPath를 써야 하면 상대 경로로. # 조금 나은 예 driver.find_element_by_xpath("//button[contains(text(), '로그인')]")텍스트로 찾는다. 텍스트는 안 바뀐다. (국제화하면 바뀐다. 그것도 문제다.) 우선순위를 정하니 스크립트가 안정적이 됐다. 깨지는 빈도가 70% 줄었다. 대기 전략도 중요하다 로케이터만 문제가 아니었다. 가끔 요소를 찾는데 "ElementNotInteractableException"이 뜬다. 요소는 있다. 근데 클릭이 안 된다. 왜냐. 아직 로딩 중이다. 옛날 코드를 봤다. driver.find_element_by_id("login-btn").click()요소가 나타날 때까지 안 기다린다. 바로 찾는다. 없으면 실패. time.sleep(3)을 넣었다. 3초 기다린다. time.sleep(3) driver.find_element_by_id("login-btn").click()문제가 생긴다.요소가 1초에 뜨면 2초를 낭비한다. 요소가 5초 걸리면 실패한다. 테스트가 느려진다. 전체 런타임 30분이 1시간이 됐다.해결책은 명시적 대기. Explicit Wait. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECwait = WebDriverWait(driver, 10) element = wait.until(EC.element_to_be_clickable((By.ID, "login-btn"))) element.click()최대 10초 기다린다. 요소가 클릭 가능해지면 즉시 진행한다. 1초에 뜨면 1초만 기다린다. POM에 넣었다. class LoginPage: def __init__(self, driver): self.driver = driver self.wait = WebDriverWait(driver, 10) def click_login(self): button = self.wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-testid='login-button']")) ) button.click()Flaky 테스트가 80% 줄었다. 런타임은 원래대로 돌아왔다. 개발팀과의 커뮤니케이션 기술만으로는 부족하다. 사람이 문제다. 개발자는 QA를 모른다. 테스트 스크립트가 있는지도 모른다. 버튼 ID 바꾸면 테스트가 깨지는지 모른다. 몰라서 그런다. 알려줘야 한다. 1. PR에 QA 태그 요청 프론트 개발자한테 부탁했다. "UI 바꾸는 PR엔 저 태그해주세요." 처음엔 깜빡했다. 그래도 계속 말했다. 지금은 습관이 됐다. 태그 받으면 PR을 본다. data-testid가 바뀌나 확인한다. 바뀌면 댓글 단다. "이 속성 테스트에서 쓰고 있어요. 바꾸면 스크립트 수정해야 합니다." 대부분은 안 바꾸는 쪽으로 간다. 가끔은 같이 수정한다. 2. 테스트 커버리지 공유 월요일 스탠드업 때 공유한다. "로그인 플로우 자동화 완료했습니다. 이제 이 부분 건드리면 자동으로 테스트됩니다." 개발자가 안다. 어느 부분이 자동화됐는지. 조심하게 된다. 3. 깨진 테스트 즉시 알림 Jenkins가 실패하면 슬랙에 알람 간다. 개발팀 채널에. "[E2E Test Failed] Login flow broken by commit abc123" 커밋 해시까지 보인다. 누가 깨트렸는지 바로 안다. 처음엔 싫어했다. "버그도 아닌데 왜 알람 와요?" 설명했다. "스크립트가 깨진 것도 비용입니다. 고치는 데 시간이 듭니다." 지금은 자기가 커밋하면 테스트 결과를 확인한다. 깨지면 바로 연락 온다. 4. 테스트용 속성 가이드 문서 Confluence에 문서를 만들었다. "E2E 테스트 친화적인 프론트엔드 개발 가이드"data-testid 네이밍 규칙 동적 ID 피하기 (타임스탬프, 랜덤 문자열) 테스트에서 사용 중인 셀렉터 목록신입 프론트 개발자 온보딩 때 읽게 했다. 리드 개발자가 코드 리뷰할 때 체크한다. 문화가 바뀌었다. 개발자가 먼저 물어본다. "이거 테스트에서 쓰는 거죠?" 그래도 깨질 때 모든 걸 해도 깨진다. 리팩토링. 디자인 시스템 전면 개편. 프레임워크 마이그레이션. 지난달에 React 16에서 18로 올렸다. 렌더링 방식이 바뀌었다. 타이밍이 달라졌다. 테스트 34개가 깨졌다. 어쩔 수 없다. 고친다. 하지만 POM 덕분에 빠르다. 34개 테스트를 고쳤지만 실제로는 5개 페이지 클래스만 수정했다. 전엔 34개 파일을 열어서 일일이 고쳤다. 지금은 5개. 시간이 1/7로 줄었다. 회고 개발자가 UI 바꿀 때마다 스크립트가 폭탄 되는 이유.로케이터가 취약하다. (ID, class 직접 의존) 중복이 많다. (같은 셀렉터가 여러 파일에) 추상화가 없다. (UI와 테스트가 직접 결합) 개발팀이 모른다. (테스트 영향을 생각 안 함)해결책.POM으로 추상화한다. 로케이터 우선순위를 정한다. (data-testid > ID > CSS > XPath) 명시적 대기를 쓴다. 개발팀과 소통한다.완벽할 순 없다. UI는 계속 바뀐다. 테스트도 계속 고쳐야 한다. 하지만 구조를 잘 만들면 유지보수가 쉽다. 한 군데만 고치면 된다. 자동화는 코드다. 코드는 설계가 중요하다. 설계 없이 짠 코드는 레거시가 된다. 테스트 코드도 마찬가지다. 지금 LoginPage 클래스를 본다. 깔끔하다. ID가 바뀌어도 여기만 고치면 된다. 89개 파일을 열던 시절이 기억난다. 지금은 1개만 연다. POM이 답이었다.새벽 알람은 여전히 온다. 하지만 이젠 30초면 고친다. 다시 잔다.

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가

테스트 피라미드: 왜 E2E 테스트만 100개 돌리는 팀은 실패하는가 월요일 아침, 빌드가 깨졌다 출근했다. 슬랙에 알림 37개. "빌드 실패", "테스트 타임아웃", "CI 30분째 돌아가는 중". 아침 9시. 젠킨스 보니까 E2E 테스트가 2시간째 돌고 있다. 103개 테스트 중 47번째에서 멈췄다. 셀레니움이 로딩 스피너를 못 찾는다. 또. 옆자리 개발자가 말했다. "배포 언제 돼요? 오전 회의에서 발표해야 하는데." 나도 모른다. 테스트가 끝나봐야 안다. 이게 3일째다.작년 이맘때, 우리는 E2E를 사랑했다 입사 1년 전 이야기다. 그때 우리 팀은 E2E 테스트를 막 도입했다. PM이 말했다. "실제 사용자 시나리오를 테스트해야죠." 맞는 말이었다. 첫 E2E 테스트를 짰다. 로그인 → 상품 검색 → 장바구니 → 결제. 완벽했다. 실제 브라우저에서 돌아갔다. 버그를 3개 찾았다. 개발팀이 감탄했다. "역시 E2E네요!" 그래서 더 짰다. 회원가입 시나리오. 10개 케이스. 결제 시나리오. 15개 케이스. 마이페이지. 8개 케이스. 6개월 후, E2E 테스트가 103개였다. 실행 시간은 2시간 30분. 이때부터 문제가 시작됐다. 금요일 오후, 아무도 배포 안 하려는 이유 금요일 4시. 개발자가 PR 올렸다. "간단한 버그 픽스입니다. 머지 부탁드려요." 나도 간단해 보였다. CSS 한 줄 수정. 그런데 규칙이 있었다. "모든 PR은 E2E 테스트 통과 후 머지." 버튼 눌렀다. 젠킨스가 103개 테스트를 돌리기 시작했다. 2시간 기다렸다. 92번째 테스트에서 실패. "Element not found: 로그아웃 버튼." CSS 수정인데 왜 로그아웃 테스트가 깨지나. 로그 봤다. 타임아웃이었다. 페이지 로딩이 평소보다 2초 늦었다. 재실행했다. 이번엔 통과했다. 시계 봤다. 저녁 7시. 개발자는 퇴근했다. 금요일 배포는 없었다. 다음 주 월요일에 머지했다. CSS 한 줄 때문에 3일 걸렸다.화요일 점심, CTO가 물었다 회의실. CTO가 들어왔다. "배포 속도가 너무 느립니다. 경쟁사는 하루 10번 배포하는데." 개발 리드가 말했다. "테스트 때문입니다. 2시간 넘게 걸려요." CTO가 나를 봤다. "테스트를 줄일 수 있나요?" 나는 답했다. "어떤 테스트를요? 다 중요합니다." CTO가 물었다. "정말 103개가 다 필요한가요?" 솔직히 모르겠다. 6개월 동안 쌓다 보니 103개가 됐다. 어느 게 중요한지 구분 못 했다. 회의 끝나고 책상 돌아왔다. 구글링했다. "E2E test too slow". 테스트 피라미드가 나왔다. 마틴 파울러의 글이었다. 그때 깨달았다. 우리는 피라미드를 거꾸로 세우고 있었다. 테스트 피라미드, 내가 이해한 것 그림을 그렸다. 삼각형. 피라미드. 밑에서부터:유닛 테스트 (70%) 통합 테스트 (20%) E2E 테스트 (10%)우리 팀 현실:유닛 테스트 (5%) 통합 테스트 (5%) E2E 테스트 (90%)완전 거꾸로였다. 왜 피라미드 모양이어야 하나. 이유는 간단했다. 속도. 유닛 테스트: 0.1초. 통합 테스트: 2초. E2E 테스트: 90초. 안정성. 유닛 테스트: 깨지면 코드 문제. 통합 테스트: 깨지면 연동 문제. E2E 테스트: 깨지면... 네트워크? 타임아웃? 셀레니움? 모름. 유지보수. 유닛 테스트: 함수 이름 바꾸면 끝. 통합 테스트: API 스펙 바꾸면 수정. E2E 테스트: UI 바뀌면 셀렉터 다 깨짐. 계산해봤다. 103개 E2E를 70개 유닛 + 20개 통합 + 13개 E2E로 바꾸면. 실행 시간:기존: 2시간 30분 변경 후: 12분10배 빨라진다.수요일 오전, 설득 시작 개발팀 회의. 화이트보드에 피라미드 그렸다. "우리는 지금 이렇게 테스트합니다." 거꾸로 된 피라미드. "이래야 합니다." 정상 피라미드. 개발자 한 명이 물었다. "유닛 테스트로 사용자 시나리오를 어떻게 검증해요?" 좋은 질문이었다. "검증 안 합니다. 유닛 테스트는 함수를 테스트해요." 예시 들었다. 로그인 기능. 기존 E2E 테스트:브라우저 열기 로그인 페이지 가기 아이디 입력 비밀번호 입력 로그인 버튼 클릭 메인 페이지 확인 프로필 메뉴 확인 로그아웃 버튼 확인90초 걸렸다. 새로운 방식: 유닛 테스트 (3개):비밀번호 검증 함수 테스트 (0.1초) 토큰 생성 함수 테스트 (0.1초) 세션 저장 함수 테스트 (0.1초)통합 테스트 (2개):로그인 API 테스트 (2초) 토큰 검증 API 테스트 (2초)E2E 테스트 (1개):실제 로그인 시나리오 (30초)총 실행 시간: 34.3초. 기존 대비 62% 빠르다. 더 중요한 건 안정성이었다. E2E가 깨지면 원인 찾기 어렵다. 유닛이 깨지면 정확히 어느 함수가 문제인지 안다. 개발 리드가 말했다. "좋은데, 누가 유닛 테스트 짜죠?" "개발자들이요." 분위기가 싸해졌다. 목요일 오후, 반발 개발자 세 명이 찾아왔다. "유닛 테스트 짜는 시간 있으면 기능 개발하겠습니다." 예상했던 반응이다. 물었다. "지난주 버그 기억나요? 결제 금액 계산 오류." 기억한다는 표정이었다. "그거 유닛 테스트 있었으면 5분 만에 찾았어요. 우리는 2시간 걸렸죠." 계산해줬다. 한 달 버그 수정 시간: 40시간. 유닛 테스트 작성 시간: 주당 2시간, 한 달 8시간. 이득: 32시간. "기능 개발 시간 더 생깁니다." 한 명이 고개 끄덕였다. 다른 개발자가 물었다. "QA가 도와줄 건가요?" "유닛은 어렵습니다. 코드를 제가 다 모르니까. 대신 통합 테스트는 제가 짜겠습니다." 역할 분담했다.개발자: 유닛 테스트 (함수, 클래스) 나: 통합 테스트 (API, 데이터베이스) 나: E2E 테스트 (핵심 시나리오만)일주일 시범 운영 제안했다. CTO가 승인했다. 2주 후, 숫자로 말하기 스프레드시트 만들었다. 비교표. 기존 방식 (2주):총 테스트: 103개 (E2E 100개, 통합 3개) 실행 시간: 평균 2시간 18분 빌드 실패율: 23% (Flaky 테스트) 평균 배포 시간: PR 머지부터 4.2시간 버그 발견: 17개 프로덕션 버그: 5개새 방식 (2주):총 테스트: 187개 (유닛 132개, 통합 38개, E2E 17개) 실행 시간: 평균 14분 빌드 실패율: 8% 평균 배포 시간: 28분 버그 발견: 31개 프로덕션 버그: 2개숫자가 말해줬다. 테스트는 81% 늘었다. 시간은 90% 줄었다. 버그는 더 많이 찾았다. 프로덕션 버그는 60% 줄었다. 개발자들이 놀랐다. "유닛 테스트가 버그를 이렇게 많이 찾네요." 당연했다. 유닛 테스트는 엣지 케이스를 확인한다. E2E는 해피 패스만 확인한다. 예시. 결제 금액 계산 함수. E2E로는 1개 케이스만 확인했다. "10,000원 상품 + 2,500원 배송비 = 12,500원" 유닛으로는 12개 케이스 확인했다.음수 금액 0원 천억 원 소수점 null 값 할인 적용 쿠폰 중복 등등실제로 버그 3개 찾았다. 음수 금액 처리 안 됨. 쿠폰 중복 적용됨. 천억 원 넘으면 오버플로우. E2E로는 절대 못 찾을 버그들이었다. 한 달 후, E2E 17개 선택 기준 회의. "E2E 103개 중 17개만 남기기." 기준 세웠다. 남길 E2E:매출 직결 시나리오 (결제, 환불) 유닛/통합으로 커버 안 되는 것 (브라우저 특성) 여러 시스템 연동 필수 (결제사, 배송사) 사용자 경험 크리티컬 (회원가입, 로그인) 법적 필수 기능 (개인정보 처리)버릴 E2E:유닛으로 커버 가능 (계산, 검증) 통합으로 충분 (API 호출) UI만 다른 중복 케이스 (버튼 위치만 다름) Flaky한 테스트 (타임아웃 자주 남) 비즈니스 임팩트 낮음 (어드민 페이지 필터)103개 리스트 출력했다. 하나씩 분류했다. 결과:유닛으로 이동: 61개 통합으로 이동: 25개 E2E 유지: 17개17개 E2E 리스트:회원가입 (이메일 인증 포함) 로그인 (소셜 로그인 포함) 상품 검색 → 상세 → 장바구니 결제 (카드, 계좌이체, 카카오페이) 주문 조회 환불 신청 리뷰 작성 1:1 문의 배송지 변경 쿠폰 적용 포인트 사용 정기 구독 신청 구독 해지 비밀번호 찾기 회원 탈퇴 장바구니 비우기 최근 본 상품이 17개만 돌리면 35분 걸렸다. 충분했다. 핵심 사용자 플로우는 다 커버했다. 3개월 후, 새로운 문제 평화로웠다. 배포는 빨라졌다. 하루 평균 8번 배포했다. 그런데 새로운 문제가 생겼다. 유닛 테스트가 너무 많아졌다. 418개. 개발자들이 열심히 짰다. 너무 열심히 짰다. 실행 시간이 3분으로 늘었다. 예전엔 30초였다. 왜? 봤더니 중복 테스트가 많았다. 예시. 회원가입 API. 유닛 테스트:이메일 형식 검증 (유닛) 비밀번호 길이 검증 (유닛) 닉네임 특수문자 검증 (유닛)통합 테스트:이메일 형식 검증 (통합) 비밀번호 길이 검증 (통합) 닉네임 특수문자 검증 (통합)중복이었다. 개발자한테 말했다. "유닛에서 검증했으면 통합에서 또 할 필요 없어요." "그럼 통합에서는 뭘 테스트해요?" "연동이요. 데이터베이스에 제대로 저장되는지. 이메일이 발송되는지." 정리했다. 유닛 테스트 역할:비즈니스 로직 계산, 검증, 변환 빠른 피드백통합 테스트 역할:시스템 연동 데이터베이스, API, 외부 서비스 실제 환경 검증E2E 테스트 역할:사용자 시나리오 여러 시스템 통합 최종 확인역할이 명확해지니까 중복이 줄었다. 418개 유닛 → 312개. 실행 시간 3분 → 1분 20초. 6개월 후, 숫자 보고 CTO 보고. 반년 데이터. 테스트 현황:유닛: 312개 통합: 94개 E2E: 17개 총: 423개실행 시간:전체: 평균 16분 유닛만: 1분 20초 통합까지: 9분 E2E 포함: 16분배포 속도:하루 평균: 11.3번 6개월 전: 1.4번 증가율: 707%품질:프로덕션 버그: 월 2.1개 6개월 전: 월 8.7개 감소율: 76%개발 생산성:기능 개발 시간: 37% 감소 (버그 수정 시간 줄어서) 핫픽스: 월 1.2회 (기존 5.8회)CTO가 만족했다. "다른 팀에도 적용합시다." 복잡했다. 지금, 다른 팀 코칭 중 지금은 백엔드팀 코칭 중이다. 그들도 비슷했다. 통합 테스트 200개. 실행 시간 40분. 피라미드 그려줬다. "유닛 테스트 먼저 짜세요." 그들이 물었다. "API 테스트가 있는데 왜 유닛이 필요해요?" 설명했다. API 테스트는 느리다. 서버 띄우고, DB 연결하고, 요청 보내고. 한 테스트에 2초. 유닛 테스트는 빠르다. 함수만 호출. 한 테스트에 0.01초. 200배 차이. 더 중요한 건 디버깅이다. API 테스트가 깨지면:라우팅 문제? DB 문제? 비즈니스 로직 문제? 권한 문제? 네트워크 문제?모른다. 다 확인해야 한다. 유닛 테스트가 깨지면:정확히 이 함수가 문제.끝. 백엔드팀이 2주째 시도 중이다. 유닛 78개 추가했다. 통합 200개 → 143개로 줄였다. 실행 시간 40분 → 18분. 잘되고 있다. 내가 배운 것들 6개월간 배웠다. 1. E2E는 마지막 방어선이다. 모든 걸 E2E로 테스트하려고 하면 망한다. E2E는 비싸다. 느리고, 불안정하고, 유지보수 어렵다. 핵심만 남겨라. 2. 유닛 테스트가 제일 중요하다. 처음엔 안 믿었다. "사용자 시나리오를 어떻게 유닛으로 테스트해?" 하지만 버그의 80%는 로직 문제다. 로직은 유닛으로 잡는다. 3. 피라미드는 속도다. 10분 안에 피드백 받아야 한다. 그래야 개발자가 컨텍스트 유지한다. 2시간 후 피드백은 의미 없다. 4. 테스트도 코드다. 중복 제거해야 한다. 리팩토링 해야 한다. 유지보수 해야 한다. 테스트가 많다고 좋은 게 아니다. 5. 역할 분담이 명확해야 한다. 유닛, 통합, E2E의 역할을 모두가 이해해야 한다. 안 그러면 중복 테스트 천지가 된다. 6. 숫자로 말해야 한다. "E2E 줄입시다"는 설득 안 된다. "실행 시간 90% 줄고, 버그 60% 줄어듭니다"는 설득된다. 7. 점진적으로 바꿔야 한다. 한 번에 103개 다 못 바꾼다. 일주일 시범, 한 달 확대, 석 달 정착. 이게 현실이다. 여전히 어려운 것 완벽하진 않다. E2E 선택 기준이 주관적이다. "이건 E2E로 해야 해요" vs "통합으로 충분해요" 여전히 의견 갈린다. 내 기준:돈이 오가는가? → E2E UI 인터랙션이 복잡한가? → E2E API만 호출하는가? → 통합 함수 호출인가? → 유닛하지만 애매한 경우가 많다. 개발자 설득이 계속 필요하다. 신입이 들어온다. 또 설명해야 한다. "왜 이런 걸 유닛으로 짜요?" 피라미드 그림 그리는 게 일상이 됐다. Flaky 테스트는 여전하다. E2E 17개 중 3개는 가끔 깨진다. 네트워크 타임아웃. 타이밍 이슈. 재시도 로직 넣었다. 3번 재시도. 2번 성공하면 통과. 완벽한 해결은 아니다. 커버리지 압박. PM이 물어본다. "커버리지 몇 퍼센트예요?" "유닛 78%, 통합까지 합치면 86%." "왜 100% 아니에요?" 설명한다. "100% 커버리지가 목표가 아니에요. 중요한 로직 커버가 목표예요." 이해 안 할 때도 있다. E2E 유지보수는 여전히 힘들다. UI 바뀌면 E2E 깨진다. 버튼 ID 바뀜. 셀렉터 수정. CSS 클래스 바뀜. 다시 수정. 17개로 줄었지만 여전히 일이다. 금요일 저녁, 배포 금요일 5시. 개발자가 PR 올렸다. "새 기능: 위시리스트 공유" 리뷰했다. 머지 승인. CI 돌아간다. 유닛 312개: 1분 20초. 통과. 통합 94개: 7분 40초. 통과. E2E 17개: 6분 30초. 통과. 총 15분 30초. 배포 버튼 눌렀다. 프로덕션 올라갔다. 5시 17분에 시작해서 5시 33분에 끝났다. 개발자한테 슬랙 보냈다. "배포 완료. 확인해보세요." "벌써요? 감사합니다!" 퇴근했다. 6시 10분. 작년 같았으면 9시까지 E2E 돌아가는 걸 지켜봤을 것이다. 지금은 정시 퇴근한다. 테스트 피라미드 덕분이다.E2E 100개 돌리는 팀은 느리고, 불안정하고, 결국 무너진다. 피라미드는 거꾸로 세우는 게 아니다. 밑에서부터 차곡차곡 쌓는 거다. 그게 빠르고, 안정적이고, 지속 가능한 방법이다.