Showing Posts From

복잡한

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년 전에는 상상도 못 했다.