Showing Posts From

자동화

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

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

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

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

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

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

개발자 코드 리뷰처럼 테스트 코드 리뷰받기

개발자 코드 리뷰처럼 테스트 코드 리뷰받기

개발자 코드 리뷰처럼 테스트 코드 리뷰받기 테스트 코드도 코드다 오늘 개발팀 코드 리뷰에 참석했다. PR 하나에 댓글이 37개. 네이밍부터 로직까지 전부 뜯어본다. 근데 내 테스트 코드 PR은 댓글 2개. "LGTM", "Approved". 뭔가 이상하다고 느꼈다. 테스트 코드도 코드인데 왜 이렇게 대충 봐줄까. 프로덕션 코드는 30분 검토하고, 테스트 코드는 3분. 이해는 간다. 테스트 코드는 "그냥 돌아가면 되는 거" 아닌가. 근데 정말 그럴까. 지난주 배포 장애가 터졌다. E2E 테스트는 전부 통과했는데 실제론 버그. 알고 보니 테스트가 잘못 짜여 있었다. 그걸 아무도 몰랐다. 리뷰 때 대충 봤으니까.그날 깨달았다. 테스트 코드 리뷰를 개발 코드만큼 엄격하게 해야 한다고. 아니, 어쩌면 더 엄격해야 할 수도. 왜냐면 테스트 코드가 틀리면 버그를 못 잡으니까. 처음엔 팀원들이 이해 못 했다. "그냥 테스트인데 뭘 그렇게 까다롭게 봐요?" "기능 개발도 바쁜데 테스트 코드까지 리뷰해요?" 설득이 필요했다. 데이터를 모았다. 지난 3개월 프로덕션 버그 분석. 결과는 명확했다. 버그 42건 중 18건이 테스트 코드 문제. 테스트가 잘못 짜여 있거나, 엣지 케이스를 놓쳤거나, 아예 테스트가 없었거나. 팀 회의에서 공유했다. 분위기가 바뀌었다. 리뷰 체크리스트 만들기 막상 테스트 코드 리뷰를 시작하니 또 문제. 뭘 봐야 하는지 모르겠다는 거다. 개발 코드 리뷰는 레퍼런스가 많다. 클린 코드, SOLID 원칙, 디자인 패턴. 근데 테스트 코드는? "테스트가 통과하면 되는 거 아니에요?" 아니다. 통과하는 건 최소한이다. 제대로 테스트하고 있는지 봐야 한다. 체크리스트를 만들었다. 처음엔 10개 항목으로 시작. 지금은 25개까지 늘었다. 기본 항목테스트 이름이 명확한가 한 테스트에서 하나만 검증하는가 Given-When-Then 구조가 명확한가 하드코딩된 값이 있는가 Sleep이나 고정 대기 시간이 있는가신뢰성 항목Flaky할 가능성은 없는가 외부 의존성을 제대로 모킹했는가 테스트 순서에 의존하지 않는가 실패 시 원인을 바로 알 수 있는가 타임아웃 설정이 적절한가유지보수 항목중복 코드가 없는가 픽스처/헬퍼 함수를 재사용하는가 UI 변경 시 영향도가 최소화되는가 테스트 데이터 생성이 명확한가 실패 메시지가 구체적인가체크리스트를 팀 위키에 올렸다. PR 템플릿에도 추가했다. "테스트 코드 리뷰 체크리스트 확인 완료" 처음엔 귀찮아했다. 리뷰 시간이 두 배로 늘었으니까. 근데 효과는 바로 나타났다. 2주 만에 Flaky 테스트가 40%에서 15%로 줄었다. 리뷰에서 미리 잡았기 때문이다. "여기 waitForElement 대신 explicit wait 쓰세요." "이 assertion은 너무 느슨한데요. 구체적으로 값 확인하세요." 한 달 후엔 테스트 실패 원인 파악 시간이 절반으로 줄었다. 실패 메시지가 명확해졌으니까. Before: "Test failed" After: "Login button should be enabled after valid email input, but was disabled" 리뷰 문화 만드는 법 체크리스트만으론 안 된다. 문화가 필요하다. 처음엔 내가 모든 테스트 코드를 리뷰했다. PR마다 최소 5개 이상 댓글. "이건 왜 이렇게 짰어요?" "여기 엣지 케이스 빠졌는데요?" "이 테스트 이름은 의도가 안 보여요." 팀원들 반응은 두 가지. 절반은 짜증. "너무 깐깐한 거 아니에요?" 절반은 감사. "이런 거까지 봐주시네요." 3주 정도 지나니 변화가 보였다. 팀원들이 서로 테스트 코드를 리뷰하기 시작했다. 내가 지적했던 포인트를 다른 사람 PR에서도 잡는 거다. "여기 assertion이 너무 일반적인데, 구체적으로 바꾸면 어때요?" "이 테스트 100줄인데 헬퍼 함수로 분리하는 게?" "waitFor 조건이 애매해서 Flaky할 것 같아요." 문화가 생기기 시작했다. 월간 "테스트 코드 리뷰 챔피언"을 뽑았다. 가장 의미 있는 리뷰 댓글을 단 사람. 상품은 스타벅스 기프티콘 5만원. 별거 아닌데 효과는 있었다. 사람들이 리뷰에 더 신경 쓰기 시작했다. "이번 달은 내가 챔피언 할 거야." 약간 게임처럼 됐다. 그리고 규칙을 하나 더 만들었다. "테스트 코드 approve 없이는 머지 불가" 개발 코드는 2명 approve 필요. 이제 테스트 코드도 마찬가지. 최소 1명은 체크리스트 기반으로 꼼꼼히 봐야 한다. 처음엔 병목이 됐다. 리뷰 대기 시간이 길어졌다. 근데 2주 정도 지나니 적응됐다. 오히려 배포 전 발견되는 버그가 줄어서 전체 속도는 빨라졌다. 실제 리뷰 사례 지난주 후배 J가 올린 PR. 로그인 E2E 테스트 추가. 코드는 이랬다. def test_login(): driver.get("https://example.com") driver.find_element(By.ID, "email").send_keys("test@test.com") driver.find_element(By.ID, "password").send_keys("password123") driver.find_element(By.ID, "submit").click() time.sleep(3) assert "Dashboard" in driver.title테스트는 통과했다. 근데 문제가 많았다. 내 리뷰 댓글: 1. 테스트 이름이 너무 일반적 ❌ test_login ✅ test_login_with_valid_credentials_shows_dashboard2. 하드코딩된 URL과 credentials # Before driver.get("https://example.com")# After driver.get(config.BASE_URL) email = test_data.get_valid_user()["email"]3. time.sleep(3) 사용 # Before time.sleep(3)# After WebDriverWait(driver, 10).until( EC.title_contains("Dashboard") )4. assertion이 너무 느슨함 # Before assert "Dashboard" in driver.title# After assert driver.title == "Dashboard - Welcome" assert driver.find_element(By.CLASS_NAME, "user-name").text == "Test User"5. Given-When-Then 구조가 불명확 def test_login_with_valid_credentials_shows_dashboard(): # Given login_page = LoginPage(driver) valid_user = test_data.get_valid_user() # When login_page.enter_email(valid_user["email"]) login_page.enter_password(valid_user["password"]) login_page.click_submit() # Then dashboard_page = DashboardPage(driver) assert dashboard_page.is_displayed() assert dashboard_page.get_welcome_message() == f"Welcome, {valid_user['name']}"J가 처음엔 당황했다. "테스트 통과했는데 왜 이렇게 고쳐야 해요?" 설명했다. "지금은 통과해. 근데 내일 프론트가 타이틀 바꾸면 깨져. 모달 하나 더 뜨면 타이밍 꼬여. 누가 이 테스트 보면 뭘 검증하는지 모르겠어." "이건 지금은 테스트고, 3개월 후엔 레거시야. 6개월 후엔 아무도 안 만지는 코드. 그때 가서 고치려면 2시간 걸려. 지금 30분 투자하면 그걸 막을 수 있어." J가 수정해서 다시 올렸다. 완전히 달라졌다. Page Object Pattern 적용, 명확한 wait 조건, 구체적인 assertion. 머지 후 Slack에 메시지가 왔다. "이제 왜 리뷰가 중요한지 알겠어요. 제 코드가 훨씬 나아졌어요." 이게 문화다. 리뷰에서 자주 잡히는 것들 3개월간 테스트 코드 리뷰 데이터를 정리했다. 총 PR 85개, 리뷰 코멘트 428개. TOP 5 지적 사항 1위: 불명확한 wait 조건 (78회) # Bad time.sleep(2) time.sleep(5) implicit_wait(10)# Good WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "result")) )대부분 "그냥 돌아가게" 하려고 sleep을 넣는다. 근데 이게 Flaky의 90%를 차지한다. 로컬에선 되는데 CI에서 실패하는 이유. 2위: 너무 일반적인 assertion (62회) # Bad assert response.status_code == 200 assert element.is_displayed() assert len(results) > 0# Good assert response.status_code == 200 assert response.json()["status"] == "success" assert element.is_displayed() and element.is_enabled() assert len(results) == 3 assert results[0]["title"] == "Expected Title""일단 통과하게" 만들려다 보면 이렇게 된다. 근데 이런 테스트는 버그를 못 잡는다. 버그가 있어도 통과하니까. 3위: 테스트 간 의존성 (54회) # Bad def test_1_create_user(): global user_id user_id = create_user()def test_2_update_user(): update_user(user_id) # test_1에 의존# Good @pytest.fixture def created_user(): user_id = create_user() yield user_id delete_user(user_id)def test_update_user(created_user): update_user(created_user)테스트 순서에 의존하면 안 된다. pytest는 순서 보장 안 한다. parallel 실행하면 무조건 깨진다. 4위: 하드코딩 (47회) # Bad driver.get("https://dev.example.com") login("admin@example.com", "password123") assert element.text == "John Doe"# Good driver.get(config.BASE_URL) login(test_user.email, test_user.password) assert element.text == test_user.full_name환경 바뀌면 바로 깨진다. dev에서 staging으로, staging에서 prod로. 5위: 불명확한 테스트 이름 (43회) # Bad test_user() test_api() test_button_click() test_scenario_1()# Good test_user_registration_with_valid_email_creates_account() test_api_returns_404_when_resource_not_found() test_submit_button_disabled_when_form_invalid() test_checkout_flow_with_multiple_items_calculates_total_correctly()테스트 이름은 문서다. 실패했을 때 이름만 봐도 뭐가 문제인지 알아야 한다. 리뷰어 가이드 리뷰하는 것도 배워야 한다. 처음엔 다들 뭘 어떻게 봐야 할지 몰랐다. 가이드를 만들었다. 리뷰 순서테스트 이름부터 읽기 - 의도 파악 Given-When-Then 구조 확인 - 논리 흐름 Assertion 체크 - 실제로 뭘 검증하는지 Wait/Sleep 확인 - Flaky 가능성 하드코딩 찾기 - 유지보수성 중복 코드 확인 - 리팩토링 필요성좋은 리뷰 코멘트 예시 ❌ "이거 이상한데요?" ✅ "이 assertion은 element가 존재하는지만 확인하는데, 실제로는 올바른 값을 가지고 있는지도 검증해야 할 것 같습니다. expected_value와 비교하는 건 어떨까요?"❌ "바꾸세요" ✅ "time.sleep(2)는 CI 환경에서 불안정할 수 있어요. WebDriverWait로 특정 조건을 기다리는 게 더 안정적일 것 같은데, 어떤 조건을 기다려야 할까요?"❌ "이해가 안 가요" ✅ "test_user()라는 이름만으로는 이 테스트가 정확히 뭘 하는지 알기 어려운데, 더 구체적인 이름으로 바꾸면 좋을 것 같아요. 예: test_user_login_with_invalid_password_shows_error_message()"질문형으로 쓴다. 명령형보다 부드럽다. "왜 이렇게 했어요?" 보다 "이렇게 하면 어떨까요?" 그리고 대안을 제시한다. 문제만 지적하지 말고 해결책도. 개발자와 동등한 리뷰 제일 중요한 건 이거다. 테스트 코드 리뷰를 개발 코드와 똑같이 대한다. 처음엔 개발자들이 내 리뷰를 우습게 봤다. "QA가 코드를 뭘 알아?" "테스트는 그냥 돌아가면 되는 거 아니에요?" 바꿔야 했다. 내 리뷰 스탠다드를 올렸다. 개발 코드 리뷰만큼 디테일하게. 때로는 더 깐깐하게. 시니어 개발자 K의 PR. API 테스트 코드 추가. def test_api(): response = requests.get("/api/users") assert response.status_code == 200내 댓글: "이 테스트는 API가 200을 리턴하는지만 확인하는데, 실제 응답 데이터 구조나 내용은 검증하지 않네요. 스키마 검증이나 특정 필드 값 확인을 추가하면 어떨까요? 또한 에러 케이스(404, 500 등)에 대한 테스트도 필요해 보입니다." K가 처음엔 어이없어했다. "테스트인데 이 정도면 충분한 거 아니에요?" 대답했다. "K님이 짠 프로덕션 코드에서 이런 PR 올리면 approve 하실 건가요? 'return 200'만 있고 실제 비즈니스 로직은 없는 코드요. 테스트 코드도 똑같아요. 제대로 된 검증이 있어야 버그를 잡죠." 침묵. 그리고 수정된 코드. def test_get_users_returns_valid_user_list(): # Given expected_users = test_data.get_sample_users(3) # When response = requests.get(f"{config.API_BASE_URL}/api/users") # Then assert response.status_code == 200 assert response.headers["Content-Type"] == "application/json" data = response.json() assert "users" in data assert len(data["users"]) == 3 for idx, user in enumerate(data["users"]): assert "id" in user assert "email" in user assert user["email"] == expected_users[idx]["email"]def test_get_users_with_invalid_token_returns_401(): response = requests.get( f"{config.API_BASE_URL}/api/users", headers={"Authorization": "Bearer invalid_token"} ) assert response.status_code == 401 assert response.json()["error"] == "Unauthorized"완전히 달라졌다. 이제 이게 버그를 잡을 수 있는 테스트다. K가 Slack으로 메시지 보냈다. "리뷰 감사합니다. 테스트를 너무 만만하게 봤네요." 이후로 K는 내 리뷰를 진지하게 받아들였다. 다른 개발자들도 마찬가지. 지금은 내가 "Changes requested" 하면 다들 제대로 고친다. 개발 코드 리뷰어가 요청한 것처럼. 동등해졌다. 3개월 후 결과 데이터로 말한다. Flaky 테스트Before: 전체의 40% After: 12%프로덕션 버그 (테스트 코드 원인)Before: 월평균 6건 After: 월평균 1.5건테스트 실패 원인 파악 시간Before: 평균 25분 After: 평균 8분테스트 코드 가독성 점수 (팀 자체 평가)Before: 10점 만점에 5.2점 After: 8.7점테스트 커버리지Before: 62% After: 78%커버리지가 오른 이유가 재밌다. 테스트를 더 많이 짠 게 아니라, 기존 테스트가 제대로 된 검증을 하게 됐기 때문. 하나의 테스트가 여러 케이스를 제대로 커버하니 자연스럽게 올랐다. 그리고 부수 효과. 테스트 코드를 보고 기능을 이해하는 개발자가 늘었다. "이 API 어떻게 쓰는지 테스트 코드 보면 알겠네요." 테스트가 문서가 됐다. 지금도 계속 배운다 완벽하지 않다. 여전히 놓치는 게 있다. 지난주에도 리뷰를 통과한 테스트가 CI에서 깨졌다. race condition을 못 잡았다. 병렬 실행하니 DB 트랜잭션 충돌. 리뷰 체크리스트에 항목을 추가했다. "병렬 실행 시 안전한가?" 매달 회고를 한다. "이번 달 리뷰에서 놓친 것들" "새로 추가할 체크리스트 항목" "더 나은 리뷰 방법" 테스트 코드 리뷰는 계속 진화한다. 기술이 바뀌니까. 툴이 바뀌니까. 팀이 배우니까. 근데 핵심은 하나다. 테스트 코드를 프로덕션 코드만큼 진지하게 대하기. 이게 전부다.테스트 코드 리뷰 문화, 3개월 걸렸지만 이제는 당연하다. 코드는 코드니까.

Pytest 파라미터화로 테스트 케이스 수 줄이기

Pytest 파라미터화로 테스트 케이스 수 줄이기

Pytest 파라미터화로 테스트 케이스 수 줄이기 같은 코드를 100번 복붙하던 날 월요일 아침이다. 커피를 들고 자리에 앉았다. 어제 작성한 테스트 코드를 열었다. def test_login_with_valid_email(): result = login("test@email.com", "password123") assert result == Truedef test_login_with_invalid_email(): result = login("invalid", "password123") assert result == Falsedef test_login_with_empty_email(): result = login("", "password123") assert result == False50개가 넘는다. 로직은 똑같다. 입력값만 다르다. 스크롤을 내리다가 한숨이 나왔다. 이게 맞나 싶었다. 개발자 후배가 옆에서 말했다. "누나 이거 반복문 아닌가요?" 맞다. 반복문이다. 근데 테스트 코드에서 반복문 쓰면 실패 케이스 찾기 어렵다. 어떤 입력값에서 터졌는지 모른다. "그럼 파라미터화 쓰세요."파라미터화가 뭔데 Pytest의 @pytest.mark.parametrize다. 같은 테스트 로직에 여러 데이터를 넣어서 돌린다. 각 케이스가 독립적으로 실행된다. 점심 먹고 문서를 찾아봤다. 예제를 따라 쳐봤다. import pytest@pytest.mark.parametrize("email,password,expected", [ ("test@email.com", "password123", True), ("invalid", "password123", False), ("", "password123", False), ("test@email.com", "", False), ("admin@test.com", "admin123", True), ]) def test_login(email, password, expected): result = login(email, password) assert result == expected50개 함수가 5줄로 줄었다. 터미널에서 pytest를 돌렸다. 결과가 이렇게 나왔다. test_login.py::test_login[test@email.com-password123-True] PASSED test_login.py::test_login[invalid-password123-False] PASSED test_login.py::test_login[-password123-False] PASSED test_login.py::test_login[test@email.com--False] FAILED test_login.py::test_login[admin@test.com-admin123-True] PASSED어떤 파라미터에서 실패했는지 바로 보인다. 이거다. 실전에 적용하기 오후 3시. 회원가입 API 테스트를 다시 짰다. 기존 코드는 30개 함수였다. 이메일 형식, 비밀번호 길이, 닉네임 검증 다 따로였다. @pytest.mark.parametrize("email,password,nickname,expected_status", [ # 정상 케이스 ("user1@test.com", "Pass1234!", "테스터", 201), ("user2@test.com", "Valid123!", "QA엔지니어", 201), # 이메일 검증 ("invalid-email", "Pass1234!", "테스터", 400), ("", "Pass1234!", "테스터", 400), ("test@", "Pass1234!", "테스터", 400), # 비밀번호 검증 ("user@test.com", "short", "테스터", 400), ("user@test.com", "12345678", "테스터", 400), ("user@test.com", "", "테스터", 400), # 닉네임 검증 ("user@test.com", "Pass1234!", "", 400), ("user@test.com", "Pass1234!", "a", 400), ("user@test.com", "Pass1234!", "a"*21, 400), ]) def test_signup(email, password, nickname, expected_status): response = api_client.post("/signup", { "email": email, "password": password, "nickname": nickname }) assert response.status_code == expected_status30개에서 12개 케이스로 정리됐다. 코드는 15줄이다. 팀장이 코드리뷰에서 물었다. "이거 실패하면 어떤 케이스인지 알기 쉬워요?" pytest를 -v 옵션으로 돌렸다. test_signup[user1@test.com-Pass1234!-테스터-201] PASSED test_signup[invalid-email-Pass1234!-테스터-400] PASSED test_signup[user@test.com-short-테스터-400] FAILED바로 보인다. password="short" 케이스가 터졌다.ids로 테스트 이름 붙이기 문제가 하나 있었다. 파라미터가 길면 터미널 출력이 난잡하다. test_api[https://api.test.com/v1/users-POST-{"name":"test","age":25}-200] PASSED읽기 어렵다. 무슨 테스트인지 모르겠다. ids 파라미터를 추가했다. @pytest.mark.parametrize("url,method,data,expected", [ ("https://api.test.com/v1/users", "POST", {"name":"test"}, 200), ("https://api.test.com/v1/users", "GET", None, 200), ("https://api.test.com/v1/orders", "POST", {"item":"A"}, 201), ], ids=[ "create_user", "get_users", "create_order", ]) def test_api_calls(url, method, data, expected): response = api_request(url, method, data) assert response.status_code == expected출력이 깔끔해졌다. test_api[create_user] PASSED test_api[get_users] PASSED test_api[create_order] PASSED리포트도 보기 좋다. 실패해도 한눈에 파악된다. 여러 파라미터 조합하기 다음 날이다. 로그인 테스트에 브라우저별 검증이 추가됐다. Chrome, Firefox, Safari 각각 테스트해야 한다. 기존 방식이면 케이스가 3배다. 파라미터화를 중첩했다. @pytest.mark.parametrize("browser", ["chrome", "firefox", "safari"]) @pytest.mark.parametrize("email,password,expected", [ ("test@email.com", "password123", True), ("invalid", "password123", False), ("", "password123", False), ]) def test_login_multi_browser(browser, email, password, expected): driver = get_driver(browser) result = login_with_driver(driver, email, password) assert result == expected driver.quit()3개 브라우저 × 3개 케이스 = 9개 테스트가 자동으로 생성된다. test_login_multi_browser[chrome-test@email.com-password123-True] PASSED test_login_multi_browser[chrome-invalid-password123-False] PASSED test_login_multi_browser[chrome--password123-False] PASSED test_login_multi_browser[firefox-test@email.com-password123-True] PASSED ...코드는 10줄이다. 수동으로 짜면 27개 함수였다. fixture와 같이 쓰기 금요일 오후다. 결제 테스트를 작성 중이다. 각 테스트마다 사용자 데이터가 필요하다. fixture와 파라미터화를 조합했다. @pytest.fixture def user_with_balance(request): balance = request.param user = create_test_user() user.add_balance(balance) yield user user.delete()@pytest.mark.parametrize("user_with_balance,amount,expected", [ (10000, 5000, "success"), (10000, 10000, "success"), (10000, 15000, "insufficient"), (0, 1000, "insufficient"), ], indirect=["user_with_balance"]) def test_payment(user_with_balance, amount, expected): result = process_payment(user_with_balance, amount) assert result.status == expectedindirect 옵션이 핵심이다. user_with_balance 값이 fixture의 request.param으로 들어간다. 각 테스트마다 잔액이 다른 사용자가 생성된다. 테스트 종료 후 자동으로 삭제된다.CSV 파일로 테스트 데이터 관리 다음 주 월요일이다. PM이 엑셀로 테스트 케이스를 보내왔다. 50개다. 하나씩 옮기기 싫었다. CSV로 저장하고 파일을 읽었다. import csv import pytestdef load_test_data(filename): with open(filename, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) return [(row['email'], row['password'], row['expected']) for row in reader]@pytest.mark.parametrize("email,password,expected", load_test_data('login_cases.csv')) def test_login_from_csv(email, password, expected): result = login(email, password) assert result == (expected == 'True')CSV 파일: email,password,expected test@email.com,password123,True invalid,password123,False admin@test.com,admin123,True케이스 추가는 CSV만 수정하면 된다. 코드는 안 건드린다. PM이 케이스를 추가해도 나는 커밋만 하면 끝이다. 비개발자도 테스트 케이스를 관리할 수 있다. xfail과 skip 활용 수요일 오전이다. 개발자가 말했다. "이 케이스는 알려진 버그예요. 다음 주에 고칩니다." 그냥 실패하게 놔두면 CI가 빨개진다. xfail을 썼다. @pytest.mark.parametrize("input,expected", [ ("valid", "success"), pytest.param("bug_case", "success", marks=pytest.mark.xfail(reason="Known bug #123")), ("another", "success"), ]) def test_with_known_bug(input, expected): result = process(input) assert result == expectedbug_case는 실패해도 빌드가 깨지지 않는다. 리포트에 XFAIL로 표시된다. 모바일 테스트도 비슷하게 했다. @pytest.mark.parametrize("device,action,expected", [ ("iOS", "swipe", "success"), pytest.param("Android", "swipe", "success", marks=pytest.mark.skipif( condition=True, reason="Android swipe not implemented")), ("iOS", "tap", "success"), ]) def test_mobile_actions(device, action, expected): result = perform_action(device, action) assert result == expectedAndroid 케이스는 아예 건너뛴다. 구현되면 조건만 바꾸면 된다. 유지보수 고민 이제 팀 전체가 파라미터화를 쓴다. 후배가 물었다. "파라미터 너무 많으면 어떡해요?" 맞는 말이다. 파라미터가 7개 넘어가면 읽기 어렵다. 이럴 땐 데이터클래스를 쓴다. from dataclasses import dataclass@dataclass class LoginTestCase: email: str password: str remember_me: bool user_agent: str ip_address: str expected_status: int expected_token: booltest_cases = [ LoginTestCase("test@email.com", "pass123", True, "Chrome", "1.1.1.1", 200, True), LoginTestCase("invalid", "pass123", False, "Firefox", "1.1.1.2", 400, False), ]@pytest.mark.parametrize("case", test_cases, ids=lambda c: f"{c.email[:10]}") def test_login_complex(case): result = login( case.email, case.password, case.remember_me, case.user_agent, case.ip_address ) assert result.status_code == case.expected_status assert result.has_token == case.expected_token타입 힌트도 있고 읽기도 쉽다. IDE에서 자동완성도 된다. 성능 문제 금요일 저녁이다. CI가 느려졌다. 파라미터화한 테스트가 100개가 넘는다. 하나하나 DB setup/teardown 하느라 20분 걸린다. scope를 조정했다. @pytest.fixture(scope="module") def db_connection(): conn = create_db() yield conn conn.close()@pytest.mark.parametrize("data,expected", test_data) def test_with_shared_db(db_connection, data, expected): result = query(db_connection, data) assert result == expectedfixture를 module 단위로 공유한다. DB 연결은 한 번만 한다. 실행 시간이 20분에서 5분으로 줄었다. 병렬 실행도 추가했다. pytest -n 4 tests/pytest-xdist로 4개 프로세스가 돈다. 3분으로 더 줄었다. 실패 분석이 쉬워짐 월요일 아침이다. 밤새 돌린 테스트 결과를 봤다. 12개가 실패했다. 예전 같으면 하나씩 함수를 찾아야 했다. 지금은 파라미터만 보면 된다. test_api[POST-/users-invalid_email] FAILED test_api[POST-/users-empty_password] FAILED test_api[PUT-/users/123-unauthorized] FAILED패턴이 보인다. POST 요청 validation이 전부 깨졌다. 백엔드에서 validation 로직을 수정했다. 한 곳만 고치면 된다. def validate_user_input(data): # 이 부분이 버그였다 if not data.get("email"): raise ValidationError("Email required")코드 한 줄 수정하고 다시 돌렸다. 12개 전부 통과했다. 파라미터화가 없었으면 12개 함수를 다 열어봐야 했다. 지금은 3분 만에 끝났다. 통계도 명확해짐 주간 회의다. 테스트 현황을 보고했다. "로그인 테스트 45개 케이스, 회원가입 32개, 결제 28개 전부 통과했습니다." 팀장이 물었다. "각 기능당 몇 개씩인가요?" 파라미터화 덕분에 바로 답했다. # 로그인: 3개 브라우저 × 15개 케이스 = 45개 # 회원가입: 4개 필드 × 8개 검증 = 32개 # 결제: 7개 상태 × 4개 금액 = 28개코드 구조와 실제 테스트 수가 일치한다. 커버리지 계산도 정확하다. 리팩토링도 쉬움 다음 날이다. API 스펙이 바뀌었다. user_id가 userId로 변경됐다. 기존 방식이었으면 50개 함수를 다 수정해야 했다. 지금은 한 곳만 고친다. @pytest.mark.parametrize("user_data,expected", test_data) def test_user_api(user_data, expected): # 여기만 수정 payload = { "userId": user_data["user_id"], # 키 변경 "userName": user_data["user_name"] } response = api_client.post("/users", payload) assert response.status_code == expected3분 만에 끝났다. 테스트를 다시 돌렸다. 전부 통과했다. 문서화 효과 금요일 오후다. 신입이 왔다. 온보딩 중이다. "로그인 테스트 케이스가 뭐예요?" 코드를 보여줬다. @pytest.mark.parametrize("email,password,expected", [ ("valid@test.com", "Pass123!", True), # 정상 로그인 ("invalid", "Pass123!", False), # 이메일 형식 오류 ("valid@test.com", "short", False), # 비밀번호 길이 부족 ("valid@test.com", "12345678", False), # 특수문자 없음 ("", "Pass123!", False), # 빈 이메일 ("valid@test.com", "", False), # 빈 비밀번호 ], ids=[ "valid_login", "invalid_email_format", "short_password", "no_special_char", "empty_email", "empty_password", ]) def test_login(email, password, expected): result = login(email, password) assert result == expected"여기 다 있어요. 주석이 곧 문서입니다." 신입이 바로 이해했다. 어떤 케이스를 테스트하는지 코드만 봐도 안다. 별도 문서가 필요 없다. 코드와 문서가 동기화된다.파라미터화는 단순히 코드를 줄이는 게 아니다. 테스트를 데이터로 관리하는 사고방식이다. 유지보수가 쉽고 확장이 편하다. 한 번 익히면 돌아갈 수 없다.

자동화 엔지니어 vs 개발자: 내 정체성은 뭘까

자동화 엔지니어 vs 개발자: 내 정체성은 뭘까

자동화 엔지니어 vs 개발자: 내 정체성은 뭘까 7년 차, 여전히 모르겠다 아침 10시. 출근해서 테스트 결과 확인했다. 밤새 돌린 E2E 테스트 327개 중 12개 실패. 로그 열어봤다. 8개는 타임아웃, 3개는 셀렉터 변경, 1개는 진짜 버그. 이 과정이 개발인지 QA인지 모르겠다. 7년 전 신입 때는 단순했다. 매뉴얼 QA. 클릭하고 확인하고 버그 리포트. 내 역할이 명확했다. QA는 QA였다. 4년 전 자동화로 넘어오면서 모호해졌다. 코드 짜고, 아키텍처 고민하고, 리팩토링하고. 이게 개발 아닌가? 어제 신입 개발자가 물었다. "J님은 개발자세요?" 잠깐 멈췄다. 답을 못 했다.매뉴얼 3년, 명확했던 시절 2018년. 첫 회사. QA팀 막내. 테스트 케이스 엑셀로 관리했다. 손으로 하나하나 클릭. 당시엔 단순했다. 기획서 보고 → 테스트 케이스 작성 → 실행 → 버그 리포트 → 회귀 테스트. 개발팀과 경계가 분명했다. 그들은 코드를 만들고, 우리는 검증했다. "QA는 품질의 파수꾼"이라는 말에 자부심 느꼈다. 버그 찾으면 뿌듯했다. 내 역할이 명확했다. 물론 힘들었다. 반복 작업. 야근. 회귀 테스트 지옥. 한 스프린트에 300개 테스트 케이스 손으로 돌렸다. 2년 차 되던 해, 팀장이 말했다. "자동화 배워볼래?" 그때는 몰랐다. 내 정체성이 흔들리기 시작할 줄. 자동화 시작, 코드와의 첫 만남 2021년. 새 회사로 이직. 자동화 포지션. 첫날 Selenium 설치했다. Python 기초 강의 들었다. 처음엔 간단했다. driver.find_element(By.ID, "login").click()"이거면 되네?" 싶었다. 3개월 후, 현실 직면했다.Flaky 테스트: 랜덤하게 실패하는 놈들 타임아웃 문제: Wait 조건 잡기 셀렉터 깨짐: UI 조금만 바뀌면 전부 수정 테스트 데이터 관리: DB 초기화는 어떻게?"이거 개발 아냐?" 생각했다. Page Object Model 배웠다. 디자인 패턴 공부했다. pytest fixture, conftest.py, 로그 관리, 리포트 생성. 6개월 후엔 프레임워크 설계했다. base_page.py 만들고, 공통 메서드 추출하고, config 관리하고. 동료 개발자가 코드 리뷰 달았다. "여기 중복 제거할 수 있어요." 그 순간 깨달았다. 나도 개발자처럼 일하고 있다는 걸.개발자인 듯 개발자 아닌 작년 봄. 개발팀 회의에 참석했다. 마이크로서비스 아키텍처 전환 논의. 프론트엔드 개발자: "API 스펙 바뀌면 통신 다시 짜야죠." 백엔드 개발자: "DB 마이그레이션 스크립트 필요해요." 나: "테스트 환경 구성은 어떻게 하죠?" 다들 고개 끄덕였다. 나도 의견 냈다. "서비스 간 통합 테스트가 복잡해질 텐데, 모킹 전략 필요합니다." 그 자리에선 동료였다. 개발자처럼. 회의 끝나고 슬랙 메시지 왔다. "J님, 회원가입 시나리오 손으로 한 번 테스트 부탁드려요." 순간 멈칫했다. 매뉴얼 테스트. 자동화했는데 왜 또 손으로? 물어봤다. "자동화 테스트로는 안 될까요?" "프로덕션 환경이라 자동화는 좀..." 아, 맞다. 나는 개발자가 아니구나. 급여는 개발자, 취급은... 연봉 협상 때. "자동화 엔지니어는 개발자급이니까 6500 드릴게요." 좋았다. 매뉴얼 QA 평균보다 1500 높았다. 근데 조직도를 보면 QA팀 소속. 팀명: "품질관리팀" 개발자 워크샵 있을 때. 초대 안 받았다. "개발 조직만 가는 거라서..." 컨퍼런스 지원 신청했다. "코드 짜시잖아요. 개발 컨퍼런스 가세요." 가서 발표했다. "E2E 테스트 자동화 프레임워크 구축기" 청중 질문: "근데 왜 개발팀에 안 계세요?" 답 못 했다. 사내 개발자 커뮤니티 있다. 가입 신청했다. "QA팀은... 음... 관심사가 다를 것 같아서..." 거절당했다. 급여는 개발자, 소속은 QA, 일은 둘 다. 나는 뭘까.SDET라는 새로운 선택지 6개월 전. 링크드인 헤드헌터 메시지. "SDET 포지션 관심 있으세요?" SDET. Software Development Engineer in Test. 처음 들어봤다. 찾아봤다.테스트 코드도 프로덕션 코드만큼 중요 개발팀 소속, 품질 책임 TDD, CI/CD 파이프라인 관리 테스트 인프라 개발"이거 나잖아?" JD 더 봤다.코딩 테스트 필수 자료구조, 알고리즘 능력 시스템 디자인 면접 프로덕션 코드 리뷰 참여긴장됐다. 내가 개발자 코딩 테스트를 통과할 수 있을까? LeetCode 시작했다. Easy 문제부터. Two Sum 풀었다. 30분 걸렸다. 개발자는 5분 만에 푸는 문제. "나는 아직 멀었구나." 코드는 짤 줄 알지만 내 GitHub 저장소.test-automation-framework: 스타 23개 api-testing-utils: 스타 8개 selenium-helper: 스타 15개전부 테스트 관련. 프로덕션 코드는? 없다. 기여한 오픈소스는? 테스트 툴만. 이력서 technical skills:Python, JavaScript (테스트용) Selenium, Appium, Pytest Jenkins, Docker (CI/CD) API Testing, E2E Testing개발자 이력서랑 비교했다.Python, JavaScript (프로덕션) Django, React AWS, Kubernetes RESTful API 설계, 마이크로서비스방향이 다르다. 작년에 프로덕션 코드 한 번 짰다. 테스트 환경 초기화 스크립트. 200줄. 시니어 개발자가 리뷰했다. "여기 에러 핸들링 약하네요." "로깅 레벨 잘못 잡았어요." "이건 유틸로 빼는 게 좋겠어요." 수정하는데 3일 걸렸다. 개발자들은 하루에 이런 코드 수백 줄 짠다. 나는 200줄에 3일. "나는 개발자가 아니구나." 다시 깨달았다. 정체성 혼란의 순간들 순간 1: 채용 공고 "QA 자동화 엔지니어 채용" 요구사항: 3년 이상 개발 경험 개발 경험? 나는 QA 경험 7년인데. 순간 2: 이직 면접 면접관: "본인은 QA 출신인가요, 개발 출신인가요?" 나: "QA로 시작했지만 지금은..." 면접관: "아, QA 출신이시네요." 탈락했다. 순간 3: 팀 회식 개발팀장: "J님은 뭐 하세요?" 나: "자동화 엔지니어요." 개발팀장: "아, 테스터?" 아니라고 하고 싶었다. 근데 맞는 말 같기도 하고. 순간 4: 연봉 협상 "QA는 올해 3% 인상입니다." "근데 저 코드 짜잖아요." "그래도 QA팀이니까요." 억울했다. 순간 5: 프로젝트 회고 PM: "개발은 잘 끝났고, QA는..." 나: "저도 개발했는데요. 테스트 인프라." PM: "아 네, QA 자동화 잘하셨어요." 개발으로 인정 안 받는 느낌. 양쪽에서 다 어중간한 QA 관점에서 보면: "J님은 매뉴얼 감각이 떨어져요." "요즘 손으로 안 해봐서 그래요." 손으로 안 하는 이유? 자동화했으니까. 근데 그게 단점이 된다. 후배 QA가 찾은 UI 버그. "이거 자동화 테스트에서 왜 안 잡았어요?" 시각적 요소. 픽셀 단위 레이아웃. 자동화로 잡기 어렵다. "자동화가 만능은 아니거든." "그럼 뭐 하러 자동화해요?" 할 말 없었다. 개발 관점에서 보면: "테스트 코드 품질이 낮아요." "프로덕션 코드처럼 관리해야죠." 노력했다. 리팩토링했다. 커버리지 올렸다. 근데 개발자가 보면 여전히 부족하다. "이런 건 디자인 패턴 쓰면 좋은데..." "성능 테스트는 k6가 낫지 않을까요?" 알고는 있다. 근데 시간이 없다. 테스트 케이스 늘리는 게 우선이니까. 양쪽에서 다 중간이다. QA 중에선 제일 코드 잘 짜는 사람. 개발자 중에선 제일 테스트만 하는 사람. 자동화의 함정 자동화 시작할 때 생각했다. "이거 다 자동화하면 나는 뭐 하지?" 4년 차. 답 나왔다. 자동화 유지보수. 개발팀이 UI 리뉴얼했다. 테스트 스크립트 380개 깨졌다. 2주 동안 고쳤다. 셀렉터 전부 수정. 끝나자마자 또 깨졌다. API 스펙 변경. Flaky 테스트. 랜덤 실패하는 놈들. 원인 찾는데 3일. 고치는데 1시간. "자동화하면 편할 줄 알았는데..." 유지보수가 개발보다 어렵다. 내가 짠 코드지만 6개월 후엔 낯설다. 주석 없으면 이해 못 한다. "이거 왜 이렇게 짰지?" 테스트 커버리지 80%. "나머지 20%는요?" "자동화 어려운 케이스예요." "그럼 손으로 해야죠." 결국 매뉴얼도 병행. 자동화 엔지니어인데 손으로 테스트. 이게 맞나 싶다. 커리어 고민, SDET로 갈까 링크드인 봤다. SDET 채용 공고.구글: SDET, L4, $180K 페이스북: Software Engineer, Testing, E5 넷플릭스: Senior SDET공통점: 개발팀 소속. 요구사항 봤다.코딩 테스트 (LeetCode Medium 이상) 시스템 디자인 테스트 전략 설계 프로덕션 코드 기여마지막이 관건이다. 프로덕션 코드. 내 경험:테스트 코드 4년 프로덕션 코드 0년JD에 "테스트 코드도 프로덕션 코드"라고 써있다. 위안 삼았다. 지원했다. 스타트업 SDET. 1차 코딩 테스트. Medium 2문제. 첫 문제: Binary Tree Level Order Traversal 45분 걸렸다. 제한 시간 30분. 탈락. "아직 멀었구나." LeetCode 매일 풀기 시작했다. 퇴근하고 2시간씩. 한 달 후 다시 지원. 다른 회사. 코딩 테스트 통과. 2차 기술 면접. "테스트 인프라 어떻게 설계하셨나요?" 대답했다. 내 경험 기반. 면접관이 고개 끄덕였다. "근데 프로덕션 API는 개발해보셨어요?" "...아니요." "SDET는 기능 개발도 하거든요." 또 탈락. 내가 원하는 건 뭘까 깊이 생각해봤다. 개발자가 되고 싶은가? 100% 아니다. QA로 남고 싶은가? 100% 아니다. 그럼 뭘 원하는가?코드로 문제 해결하고 싶다 품질에 대한 책임감을 유지하고 싶다 개발자와 동등하게 대우받고 싶다 테스트만 하는 사람은 되기 싫다 기능 개발만 하는 사람도 되기 싫다모순이다. SDET가 답일까? 어쩌면 맞다. 어쩌면 아니다. SDET 된다고 정체성 혼란 사라질까? 모르겠다. 결국 라벨 문제가 아닐 수도. "나는 무엇을 하는 사람인가"가 중요한 거지. 테스트 코드 짜는 개발자? 개발하는 QA? 둘 다 맞는 것 같다. 둘 다 틀린 것 같기도. 3개월 후, 작은 변화 포지션 타이틀 바꿨다. "QA 자동화 엔지니어" → "품질 엔지니어(Quality Engineer)" QA 빼니까 기분이 다르다. 팀 회의 때 말투 바꿨다. "이거 테스트해볼게요" → "이거 검증 로직 구현할게요" 사소하지만 차이 있다. 개발팀 코드 리뷰 참여 시작했다. "테스트 가능한 코드인가" 관점으로. "여기 의존성 주입하면 목킹 쉬울 것 같아요." 개발자: "오, 좋은데요?" PR 머지됐다. 내 리뷰로. 기여한 느낌. 처음이다. 사이드 프로젝트 시작했다. 간단한 웹앱. Todo 리스트. 프로덕션 코드 짜봤다. React, Node.js, MongoDB. 테스트 코드도 짰다. 당연히. 2주 만에 완성. 배포했다. "나도 개발할 수 있구나." 물론 프로 개발자 수준은 아니다. 근데 할 수 있다는 게 중요하다. 이력서 업데이트했다. "Full-stack 경험 있음 (사이드 프로젝트)" 거짓말은 아니다. 여전히 답은 모른다 지금도 모른다. 내가 뭔지. 출근해서 코드 짠다. 퇴근하고도 코드 짠다. 주말엔 LeetCode 푼다. 월요일엔 테스트 리포트 쓴다. 어떤 날은 개발자 같다. 어떤 날은 QA 같다. 근데 요즘은 괜찮다. 굳이 하나일 필요 있나? 하이브리드면 어때? 개발도 하고 테스트도 하는 사람. 품질도 책임지고 코드도 짜는 사람. 라벨이 뭐든 상관없다. 내가 하는 일이 중요하다. "자동화 엔지니어세요?" "네, 품질 엔지니어이기도 하고, 때론 개발자이기도 해요." 이제 이렇게 답한다. 더 이상 멈칫하지 않는다. SDET로 갈지 모른다. 안 갈 수도 있다. 중요한 건 계속 성장하는 것. 코드도, 테스트도, 품질도. 7년 차. 여전히 모르지만. 괜찮다. 계속 찾아가는 중이다.정체성은 명함이 아니라 내가 하는 일로 정의되는 거다.

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트

자동화 테스트도 결국 버그다: 테스트 코드 리뷰 체크리스트 오늘 아침 CI 알림 확인했다. 빌드 실패. 다시 돌렸다. 성공. 세 번째. 실패. Flaky 테스트다. 또. 테스트 코드에 버그가 있으면 프로덕션 코드 버그는 찾는다. QA니까. 테스트 코드 버그는? 누가 찾나. 지난달 일이다. 결제 로직 수정했다. 자동화 테스트 전부 통과. 배포했다. 다음 날 고객 문의 폭주. 결제 안 된다.테스트 코드를 봤다. assert response.status_code == 200 이게 전부였다. 실제 결제는 안 됐다. 응답만 200이었다. 테스트는 통과. 버그는 프로덕션. 이날 배웠다. 테스트 코드도 결국 코드다. 코드면 버그가 있다. 가짜 안심이 제일 위험하다 자동화 테스트가 있으면 안심한다. 당연하다. 그린 체크 보면 '됐다'고 생각한다. 근데 그 체크가 거짓말이면. 3개월 전 상황. 로그인 테스트 500개. 전부 통과. 근데 실제로는 아무도 로그인 못 했다. 이유? 테스트 환경에는 DB에 테스트 계정이 있었다. 프로덕션에는 없었다. 테스트는 항상 성공. 현실은 실패. def test_login(): response = login("test@test.com", "password") assert response.status_code == 200 # 실제 로그인 됐는지는 안 봄통과한다. 매번. 근데 의미가 없다. 내가 만든 테스트 코드 체크리스트 4년 동안 삽질하면서 만들었다. PR마다 이거 본다. 1. 진짜 검증하는가 # 나쁜 예 def test_user_creation(): response = create_user({"name": "test"}) assert response.status_code == 201# 좋은 예 def test_user_creation(): response = create_user({"name": "test"}) assert response.status_code == 201 user = get_user(response.data.id) assert user.name == "test" assert user.created_at is not None상태 코드만 보면 안 된다. DB도 봐야 한다. 실제 데이터도. 작년에 회원가입 테스트가 있었다. 201 반환하면 통과. 근데 DB에는 안 들어갔다. 트랜잭션이 롤백됐는데 응답은 보냈다. 테스트는 몰랐다. 201만 봤으니까.2. 독립적인가 테스트 순서 바꿔봤나. A 테스트가 B 테스트에 의존하면 안 된다. # 나쁜 예 def test_1_create_product(): global product_id product_id = create_product().iddef test_2_update_product(): update_product(product_id) # test_1에 의존이거 진짜 많다. 병렬 실행하면 깨진다. 순서 바뀌면 깨진다. 2년 전에 이것 때문에 3일 날렸다. test_1이 실패하면 test_2도 실패. test_2가 진짜 문제인지 몰랐다. 각 테스트마다 setup, teardown 해야 한다. 귀찮다. 근데 해야 한다. 3. 명확한가 6개월 후에 내가 봐도 이해되나. # 나쁜 예 def test_api(): r = call(1, 2, True) assert r == 3# 좋은 예 def test_discount_applies_to_premium_users(): user = create_premium_user() product = create_product(price=10000) order = create_order(user, product, use_discount=True) assert order.final_price == 9000 assert order.discount_amount == 1000변수명만 봐도 알아야 한다. 매직 넘버 쓰지 말고. 의도가 보여야 한다. 작년에 후배가 쓴 테스트 봤다. test_case_1, test_case_2, test_case_3 뭘 테스트하는지 몰랐다. 후배도. 4. 빠른가 10분 걸리는 테스트는 안 돌린다. 안 돌리면 의미 없다. 우리 팀 E2E 테스트. 처음엔 45분 걸렸다. 아무도 로컬에서 안 돌렸다. CI에서만 돌렸다. PR 올리고 45분 기다렸다. 지금은 12분. 병렬 처리했다. 불필요한 sleep 제거했다. fixture 재사용했다. # 나쁜 예 def test_workflow(): time.sleep(5) # "혹시 몰라서" check_status() time.sleep(5) # 또# 좋은 예 def test_workflow(): wait_until(lambda: status_is_ready(), timeout=10) check_status()sleep은 악이다. 대부분 필요 없다. 필요하면 조건부로 기다려야 한다. 5. 안정적인가 100번 돌려서 100번 같은 결과 나오나. Flaky 테스트가 제일 짜증난다. 가끔 실패한다. 이유 모르겠다. 다시 돌리면 통과한다.우리 팀 규칙. Flaky 테스트 발견하면 바로 비활성화. 고칠 때까지 안 돌린다. 믿을 수 없는 테스트는 없는 것보다 나쁘다. 실패해도 "또 Flaky겠지" 하면 끝이다. 진짜 버그도 무시하게 된다. 흔한 원인들:타이밍 이슈 (sleep으로 해결하려는 순간 졌다) 공유 리소스 (DB, 파일, 네트워크) 랜덤 데이터 시간 의존성 (datetime.now() 쓰면...) 외부 API실제 PR 리뷰에서 본 것들 Case 1: 아무것도 안 하는 테스트 def test_send_email(): send_email("test@test.com", "subject", "body") # assert 없음이거 놀랍게도 많다. 함수 호출만 한다. 검증 없다. 에러 안 나면 통과. 근데 이메일 안 가도 에러 안 난다. 테스트는 통과. 의미는 없다. Case 2: 너무 많이 테스트 def test_user_api(): # 100줄 # 10가지 검증 # 3개 API 호출 # ...하나 실패하면 뭐가 문제인지 모른다. 테스트 하나는 하나만 검증해야 한다. Case 3: 프로덕션 코드 복사 def calculate_discount(price): return price * 0.9 if price > 10000 else pricedef test_discount(): price = 15000 expected = price * 0.9 if price > 10000 else price assert calculate_discount(price) == expected이건 같은 로직을 두 번 쓴 거다. 둘 다 틀려도 통과한다. 기대값은 명확해야 한다. 계산하지 말고 적어야 한다. def test_discount(): assert calculate_discount(15000) == 13500 assert calculate_discount(5000) == 5000Case 4: try-except 남용 def test_error_handling(): try: dangerous_operation() assert False, "should raise error" except: pass # 통과어떤 에러든 잡는다. AssertionError도 잡는다. 테스트 로직 에러도 잡는다. def test_error_handling(): with pytest.raises(SpecificError): dangerous_operation()이렇게 해야 한다. 구체적인 에러만 기대한다. 테스트 코드 리뷰할 때 내가 보는 것 PR 올라오면 이것들 본다.커버리지가 아니라 의미90% 커버리지는 중요하지 않다 중요한 로직 제대로 검증하나가 중요하다네이밍test_1 같은 거 보이면 바로 코멘트 테스트명이 문서다픽스처 재사용같은 setup 코드 반복하면 안 된다 유지보수 지옥된다외부 의존성API 호출하면 mock해야 한다 DB는 트랜잭션 롤백해야 한다에러 케이스Happy path만 테스트하는 거 많다 실패 케이스가 더 중요하다지난주에 리뷰했던 PR. 성공 케이스 10개. 실패 케이스 0개. "에러 처리는요?" "그건 개발자가 잘 했을 거예요" 안 한다. 개발자는 Happy path만 생각한다. 우리가 엣지 케이스 봐야 한다. 자동화의 함정 자동화하면 다 된 것 같다. 아니다. 자동화는 반복 작업을 줄인다. 판단은 못 한다. 잘못된 자동화는 수동보다 나쁘다. 수동이면 사람이 본다. 뭔가 이상하면 안다. 자동화는 시킨 것만 한다. 틀려도 모른다. 2년 전에 UI 자동화 했다. 버튼 클릭하고 텍스트 확인했다. 텍스트는 맞았다. 근데 레이아웃이 깨졌다. 버튼이 화면 밖으로 나갔다. 텍스트만 보는 테스트는 통과했다. 배포하고 디자이너한테 혼났다. 시각적 회귀 테스트 추가했다. 스크린샷 찍어서 비교한다. 1픽셀 차이도 잡는다. 근데 이것도 문제다. 의도된 변경도 실패로 본다. 매번 베이스라인 업데이트해야 한다. 자동화는 만능이 아니다. 도구일 뿐이다. 테스트 코드도 리팩토링한다 프로덕션 코드는 리팩토링한다. 테스트 코드는? 안 한다. 대부분. "돌아가는데 뭐" 그러다가 테스트가 레거시가 된다. 수정하기 무섭다. 건드리면 깨진다. 우리 팀은 스프린트마다 "테스트 부채" 시간이 있다. 중복 제거한다. 불필요한 테스트 삭제한다. 느린 테스트 최적화한다. 지난달에 500개 테스트를 300개로 줄였다. 실행 시간은 반으로. 커버리지는 그대로. 중복이 많았다. 같은 걸 다르게 테스트했다. 통합했다. 코드 리뷰 템플릿 PR마다 이거 붙인다. ## 테스트 체크리스트- [ ] 각 테스트가 하나의 관심사만 검증하는가? - [ ] 테스트명이 의도를 명확히 드러내는가? - [ ] 테스트 간 의존성이 없는가? - [ ] 외부 의존성을 mock/stub 했는가? - [ ] 에러 케이스를 다루는가? - [ ] Flaky 가능성은 없는가? - [ ] 로컬에서 빠르게 실행되는가? (< 10초) - [ ] Magic number 대신 의미있는 상수를 사용하는가? - [ ] Setup/teardown이 적절한가? - [ ] 실제 버그를 잡을 수 있는 테스트인가?마지막 항목이 제일 중요하다. "실제 버그를 잡을 수 있는가" 이거 아니면 의미 없다. 결국 사람이 본다 자동화가 아무리 좋아도. 결국 사람이 판단한다. 테스트가 실패하면 누가 보나. 테스트가 이상하면 누가 고치나. 테스트가 의미 있는지 누가 판단하나. 전부 사람이다. 자동화는 반복 작업만 줄인다. 생각은 못 한다. 좋은 테스트 코드는 좋은 코드다. 읽기 쉽고. 유지보수 쉽고. 명확하고. 신뢰할 수 있고. 프로덕션 코드만큼 신경 써야 한다. 아니, 더 써야 한다. 테스트 코드가 틀리면 프로덕션 버그를 못 잡는다. 그게 제일 무섭다.오늘도 Flaky 테스트 하나 고쳤다. 원인은 타임아웃이었다. 3초를 5초로 늘렸다. 근본적 해결은 아니다. 언젠가 다시 깨질 것이다. 그때 또 고치겠지.

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

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

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

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것

매뉴얼 QA 후배에게 Selenium 가르치다 깨달은 것 시작은 HR 전화 "J님, 신입 한 명 들어와요. 자동화 가르쳐주세요." 3년 매뉴얼 QA 경력자. 이름은 민지. 28살. 나는 고민했다. 뭘 먼저 가르치지. Python? HTML? Git? 결론은 "일단 Selenium 돌려보자"였다. 첫날, 내 자동화 프레임워크 보여줬다. 민지 표정이 굳었다. "선배, 이게 다 뭐예요?" Page Object Model. Config 파일. Fixture. Decorator. 설명하는데 1시간. 민지는 계속 끄덕였다. 하지만 눈이 멍했다. 그때 깨달았다. 내 코드가 생각보다 복잡하다. 첫 번째 질문: "왜 find_element 안 써요?" 민지 첫 과제. 로그인 테스트 스크립트 짜기. Selenium 공식 문서 보고 짰다. 코드 리뷰 요청 왔다. driver.find_element(By.ID, "username").send_keys("test") driver.find_element(By.ID, "password").send_keys("1234") driver.find_element(By.XPATH, "//button[@type='submit']").click()내가 짠 코드는 이랬다. self.login_page.enter_username("test") self.login_page.enter_password("1234") self.login_page.click_login_button()민지가 물었다. "선배 코드엔 find_element가 없는데요?" 나는 설명했다. Page Object Model. Locator 추상화. 유지보수성. 민지는 또 끄덕였다. 근데 다음 날 코드는 여전히 find_element 투성이. 화가 나려다 멈췄다. 민지가 이해 못 한 게 아니다. 내가 "왜"를 안 알려줬다.엘리먼트가 안 잡힐 때 민지 두 번째 과제. 검색 기능 테스트. 2시간 뒤 민지가 왔다. "선배, 이거 계속 에러나요." NoSuchElementException 나는 물었다. "wait 넣었어?" "wait요?" WebDriverWait. Explicit Wait. Implicit Wait. 설명했다. 민지는 코드에 time.sleep(3) 박았다. "아니 그게 아니라..." 나는 다시 설명했다. sleep은 나쁜 습관. 테스트 느려짐. Flaky 테스트 원인. 민지가 물었다. "그럼 왜 선배 코드엔 wait이 안 보여요?" 내 Base Page 클래스 열어봤다. 모든 메소드에 wait 내장. def _wait_and_find(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) )민지는 내 코드만 봤으니 wait을 몰랐다. 나는 당연하다고 생각한 것들. 민지에겐 보이지 않았다. 프레임워크 안에 숨어있었으니까. 세 번째 질문: "이건 왜 깨져요?" 민지 자동화 스크립트 10개 짰다. CI에 올렸다. 다음 날 아침. Jenkins 빨간불. 민지 테스트 5개 실패. 민지가 당황했다. "제 컴퓨터에선 됐는데요?" Headless 모드. 크롬 버전. 타임아웃 설정. 환경변수. 나는 하나씩 체크했다. 민지는 옆에서 봤다. "선배, 이런 거 어떻게 다 알아요?" "다 겪어봐서." 실패 원인은 타임아웃이었다. CI 서버가 느렸다. 민지 코드는 타임아웃 3초 하드코딩. 내 코드는 환경변수로 관리. TIMEOUT = os.getenv('TEST_TIMEOUT', 10)민지가 물었다. "왜 이렇게 해요?" "CI는 로컬보다 느리거든." "그럼 전부 이렇게 해야 돼요?" "응." 민지 표정이 어두워졌다. "자동화 어렵네요." 나도 그랬다고 말해줬다. 4년 전 나도 헤맸다고.내 코드 다시 보기 민지 질문이 계속됐다. "왜 fixture를 이렇게 써요?" "conftest.py는 뭐예요?" "이 decorator는 왜 만든 거예요?" 질문마다 내 코드 다시 봤다. 4년간 쌓인 코드. 나한테는 당연했다. 근데 민지 눈으로 보니 복잡했다. 주석 없는 함수. 이름만으로는 모호한 변수. 왜 이렇게 짰는지 기억 안 나는 로직. 민지는 내 코드 리뷰어가 됐다. "선배, 이 함수 이름이 뭘 하는 건지 모르겠어요." def _handle_alert(self): ...민지 말이 맞았다. alert 뜨면 accept? dismiss? 코드 봐야 안다. 리팩토링했다. def accept_alert_if_present(self): """Alert이 있으면 accept, 없으면 무시""" ...민지가 물었다. "이 try-except는 왜 있어요?" try: element.click() except ElementClickInterceptedException: self.driver.execute_script("arguments[0].click();", element)"어... 가끔 엘리먼트가 다른 거에 가려져서." "그럼 주석 달면 안 돼요?" 또 맞았다. 주석 추가했다. 민지 덕분에 내 코드가 나아졌다. 온보딩 방법 바꾸기 3주 뒤. 민지는 여전히 헤맸다. 내 접근이 틀렸다. "일단 프레임워크 써봐" 방식. 민지는 프레임워크 구조를 이해 못 했다. 왜 이렇게 짜야 하는지. 방법을 바꿨다. 온보딩 문서 만들기. 1단계: 날것의 Selenium 가장 기본부터. driver 띄우고 find_element. from selenium import webdriverdriver = webdriver.Chrome() driver.get("https://example.com") driver.find_element(By.ID, "username").send_keys("test")"이게 자동화의 시작이야. 이것만 알아도 테스트 짤 수 있어." 민지가 직접 짰다. 로그인. 검색. 장바구니. 코드는 지저분했다. 반복도 많았다. 하지만 돌아갔다. 민지 표정이 밝아졌다. "선배, 이거 재밌어요!" 2단계: 반복의 고통 민지한테 과제 줬다. "로그인 테스트 10개 짜봐." 다음 날 민지가 왔다. "선배, 코드가 너무 길어요." find_element 코드 100번 반복. Copy-paste 지옥. "개발자가 ID 바꾸면?" "다... 다 고쳐야죠." "그래서 함수로 빼는 거야." 민지는 함수를 만들었다. def login(driver, username, password): driver.find_element(By.ID, "username").send_keys(username) driver.find_element(By.ID, "password").send_keys(password) driver.find_element(By.ID, "login-btn").click()"훨씬 낫다. 이게 추상화의 시작이야."3단계: Page Object Model 민지 함수가 늘어났다. 50개. "선배, 이거 어떻게 관리해요?" "Page Object Model." 페이지별로 클래스 만들기. 로케이터 분리. 메소드로 액션 정의. class LoginPage: def __init__(self, driver): self.driver = driver self.username_input = (By.ID, "username") self.password_input = (By.ID, "password") def login(self, username, password): self.driver.find_element(*self.username_input).send_keys(username) ...이제 민지가 내 코드를 이해했다. "선배, 선배 코드가 이렇게 된 거였구나!" "응. 고통 받다보면 자연스럽게 이렇게 돼." 4단계: 프레임워크 pytest. fixture. conftest.py. Base Page. 민지는 이제 질문이 구체적이었다. "왜 fixture를 session scope로 해요?" "Base Page에 wait을 넣는 게 좋은 이유가 뭐예요?" 3주 전 질문과 달랐다. 문맥을 이해했다. 민지는 직접 Base Page 만들었다. 내 것과 비슷했다. "선배, 제가 짠 거 리뷰해주세요." 코드 봤다. 생각보다 괜찮았다. "민지야, 너 이제 자동화 엔지니어 같은데?" 민지가 웃었다. "아직 멀었어요." 민지의 역습 6주 뒤. 민지가 PR 날렸다. "선배, 이거 개선했어요." 내 retry 로직. 민지가 리팩토링했다. 원래 코드: def click_with_retry(self, locator, max_attempts=3): for i in range(max_attempts): try: element = self._wait_and_find(locator) element.click() return except: if i == max_attempts - 1: raise time.sleep(1)민지 코드: def click_with_retry(self, locator, max_attempts=3, retry_delay=1): """엘리먼트 클릭을 재시도. Stale element 대응.""" for attempt in range(max_attempts): try: element = self._wait_and_find(locator) element.click() logger.info(f"Click succeeded on attempt {attempt + 1}") return except (StaleElementReferenceException, ElementClickInterceptedException) as e: if attempt == max_attempts - 1: logger.error(f"Click failed after {max_attempts} attempts") raise logger.warning(f"Click failed, retrying... ({attempt + 1}/{max_attempts})") time.sleep(retry_delay)로깅 추가. Exception 구체화. Docstring. 내 코드보다 나았다. "Merge 할게." 민지가 좋아했다. "제 PR 첫 머지예요!" 깨달은 것들 민지 가르치면서 배웠다. 1. 내 코드는 내가 생각한 것보다 복잡하다 4년간 쌓인 코드. 당연하다고 생각한 패턴들. 초보자 눈엔 복잡한 미로. 2. "왜"를 알려줘야 한다 "이렇게 해" 방식은 안 먹힌다. 왜 Page Object Model? 왜 fixture? 왜 wait? 고통을 먼저 겪게 하고, 해결책을 제시하기. 3. 단계적 학습이 중요하다 처음부터 프레임워크 보여주기 = 실패 날것 Selenium → 반복의 고통 → 추상화 → 프레임워크 순서가 있다. 4. 질문은 코드 리뷰다 민지 질문은 내 코드의 문제점이었다. 주석 없는 함수. 모호한 네이밍. 불필요한 복잡도. 민지 덕분에 리팩토링했다. 5. 가르치면서 성장한다 민지한테 설명하려니 내가 제대로 이해 못 한 게 보였다. "이거 왜 이렇게 짰지?" 기억 안 나는 코드들. 다시 공부했다. Python decorator. Pytest fixture scope. Selenium wait 전략. 민지 때문에 내가 나아졌다. 지금 민지는 민지는 이제 자동화 스크립트 30개 관리한다. CI 빨간불 나면 혼자 고친다. PR 리뷰도 한다. 어제 민지가 말했다. "선배, 저도 후배 가르칠 수 있을 것 같아요." "그래? 뭘 먼저 가르칠 건데?" "당연히 날것 Selenium이죠. 고통부터 겪게 해야죠." 민지가 웃었다. 나도 웃었다. 민지가 진짜 이해했다. 다음 신입한테 다음 달 신입 또 온다. 이번엔 개발자 출신. 준비는 됐다. 온보딩 문서 있다. 민지한테 멘티 맡길 수도 있다. 근데 또 다를 거다. 개발자는 질문이 다를 테니까. "왜 unittest 안 쓰고 pytest 써요?" "이 구조는 왜 이렇게 짰어요?" 또 내 코드 다시 볼 거다. 또 리팩토링할 거다. 가르치는 게 배우는 거다. 민지가 증명했다.민지 질문이 내 코드를 고쳤다. 다음 신입도 기대된다.