로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리 오전 10시. 출근해서 Jenkins 확인했다. 빨간 불 15개. 어젯밤 배포 파이프라인 전부 터졌다. 슬랙에 개발자들 멘션 폭격. "J님, 테스트 왜 깨진 거예요?" 난 아무것도 안 건드렸다. 어제 퇴근 전에 PR 머지만 했다. 로컬에선 다 초록불이었는데. 로컬은 천국, CI는 지옥 # 로컬에서 100번 돌려도 성공 pytest tests/test_checkout.py # ✓ 20 passed in 45.23s근데 CI에서 돌리면. FAILED tests/test_checkout.py::test_payment_processing ElementNotInteractableException: element not interactable다시 로컬에서 돌린다. 성공한다. 10번 연속 성공. CI 다시 돌린다. 실패한다. 5번 중 3번 실패. 이게 뭐냐고.개발자들은 이런 거 처음 겪나 보다. "제 로컬에선 되는데요?" 알아. 나도 그래. 근데 CI에서 실패하면 배포를 못 한다. 그게 문제다. 첫 번째 용의자: 네트워크 타이밍 로컬에선 API 응답이 빠르다. 개발 서버가 내 컴퓨터 바로 옆이니까. CI는 다르다. 컨테이너끼리 네트워크 통신한다. 지연이 생긴다. # 내가 짠 코드 def test_user_login(): driver.get("https://dev.example.com/login") email_input = driver.find_element(By.ID, "email") email_input.send_keys("test@test.com") # 바로 submit submit_btn = driver.find_element(By.ID, "submit") submit_btn.click()로컬에선 찰나다. 페이지 로드 0.5초. CI에선 2초 걸린다. 그 사이 내 스크립트는 이미 element 찾으러 갔다. 당연히 못 찾는다. 해결법. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECdef test_user_login(): driver.get("https://dev.example.com/login") # 명시적 대기 추가 email_input = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "email")) ) email_input.send_keys("test@test.com") submit_btn = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, "submit")) ) submit_btn.click()이거 하나 바꾸는 데 오전 다 갔다. 테스트 케이스가 300개인데 하나하나 고쳐야 한다.두 번째 용의자: 병렬 실행 순서 로컬에선 테스트를 순차로 돌린다. 한 번에 하나씩. CI는 시간이 금이다. 병렬로 돌린다. 6개 워커. # .gitlab-ci.yml test: script: - pytest -n 6 # 6개 병렬문제는 테스트끼리 간섭한다는 거다. # test_cart.py def test_add_to_cart(): login("test@test.com") add_item("MacBook Pro") assert cart_count() == 1def test_remove_from_cart(): login("test@test.com") remove_item("MacBook Pro") assert cart_count() == 0순차 실행하면 문제없다. 1번 테스트가 끝나고 2번 시작. 병렬 실행하면 꼬인다.워커 1: add_to_cart 실행 중 워커 2: remove_from_cart 동시 실행 둘 다 같은 계정 사용 장바구니 카운트가 왔다갔다결과: 랜덤 실패. 해결법은 격리다. import pytest import uuid@pytest.fixture def unique_user(): email = f"test_{uuid.uuid4()}@test.com" create_test_user(email) return emaildef test_add_to_cart(unique_user): login(unique_user) add_item("MacBook Pro") assert cart_count() == 1각 테스트가 독립적인 데이터를 쓴다. 이제 병렬로 돌려도 안 꼬인다. 근데 이것도 문제다. 테스트 계정 300개가 생성된다. DB cleanup이 필요하다. @pytest.fixture def unique_user(): email = f"test_{uuid.uuid4()}@test.com" user_id = create_test_user(email) yield email delete_test_user(user_id) # teardown이제 CI 실행 시간이 늘었다. cleanup 때문에 1분 추가. 트레이드오프다. 세 번째 용의자: 환경 변수 제일 짜증 나는 케이스. 로컬 .env 파일. API_BASE_URL=http://localhost:3000 DB_HOST=localhost REDIS_URL=redis://localhost:6379CI 환경 변수. variables: API_BASE_URL: http://api-service:3000 DB_HOST: postgres-service REDIS_URL: redis://redis-service:6379URL이 다르다. 당연히 테스트가 깨진다. 더 미묘한 건 이런 거다. import os# 내 코드 def get_api_url(): return os.getenv("API_BASE_URL", "http://localhost:3000")# CI에서는 환경변수가 안 들어간다 # 왜? Docker 컨테이너 안에서 pytest 실행 # 환경변수가 컨테이너 안으로 안 들어감해결법. # .gitlab-ci.yml test: script: - export API_BASE_URL=http://api-service:3000 - pytest또는 docker-compose에서. services: test: environment: - API_BASE_URL=http://api-service:3000이것도 찾는 데 2시간 걸렸다. print 디버깅으로 겨우 찾았다. def test_api_connection(): print(f"API_BASE_URL: {os.getenv('API_BASE_URL')}") # CI 로그 보니까 None이 찍혔다Docker로 환경 동일하게 만들기 결론은 하나다. 로컬 환경을 CI 환경과 똑같이 만든다. Docker Compose 파일 작성. # docker-compose.test.yml version: '3.8'services: app: build: . environment: - API_BASE_URL=http://api:3000 - DB_HOST=postgres depends_on: - postgres - redis - api postgres: image: postgres:14 environment: - POSTGRES_PASSWORD=testpass redis: image: redis:7 api: image: our-api:latest environment: - DB_HOST=postgres이제 로컬에서도 똑같이 돌린다. docker-compose -f docker-compose.test.yml up --build docker-compose -f docker-compose.test.yml run app pytestCI에서도 똑같이. # .gitlab-ci.yml test: script: - docker-compose -f docker-compose.test.yml up -d - docker-compose -f docker-compose.test.yml run app pytest이제 로컬이랑 CI랑 환경이 동일하다. 같은 네트워크 설정. 같은 서비스 이름. 같은 환경 변수. 차이가 없으니 결과도 같다. 그래도 가끔 깨진다 환경을 맞춰도 100% 해결은 아니다. Flaky 테스트는 남는다.CI 서버 CPU가 바쁠 때 타임아웃 외부 API 호출하는 테스트 (네이버 로그인 등) 랜덤 데이터 쓰는 테스트이런 건 다른 방법이 필요하다. 외부 API는 mocking. @patch('requests.get') def test_naver_login(mock_get): mock_get.return_value.json.return_value = { "email": "test@naver.com" } # 실제 네이버 API 안 부름타임아웃은 여유 있게. # Before WebDriverWait(driver, 5) # 5초는 짧다# After WebDriverWait(driver, 15) # 15초로 늘림랜덤은 시드 고정. import random random.seed(42) # 매번 같은 랜덤 값완벽한 해결책은 없다. 그냥 확률을 낮추는 거다. 현재 상태 오후 6시. Jenkins 다시 돌렸다. 초록불 20개. 개발자들 슬랙 반응. "오 고쳤네요!" "ㄳㄳ" 나는 PR에 코멘트 남겼다. 변경사항: - 명시적 대기 추가 (WebDriverWait) - 테스트별 unique user fixture 적용 - docker-compose로 로컬/CI 환경 통일 - 환경변수 주입 확인테스트 시간: 45초 → 1분 20초 (cleanup 때문) 성공률: 60% → 95%완벽하진 않다. 95%. 가끔 1-2개는 여전히 깬다. 근데 이 정도면 된다. Flaky 테스트는 retry로 처리. # .gitlab-ci.yml test: retry: 2 # 2번까지 재시도배운 것 로컬과 CI가 다르다는 걸 인정해야 한다. "내 컴퓨터에선 되는데"는 핑계다. CI 환경을 로컬에서 재현할 수 있어야 한다. Docker가 답이다. 네트워크 타이밍은 항상 고려. 명시적 대기를 쓴다. 테스트는 독립적이어야 한다. 순서에 의존하면 병렬 실행에서 터진다. 환경 변수는 명확하게. print 디버깅으로 값 확인. 100% 안정적인 자동화는 없다. 95%면 성공이다. 나머지 5%는 retry와 monitoring으로.퇴근 7시. 오늘은 정시다. CI 초록불 보니까 기분이 좋다.

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

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

개발자 코드 리뷰처럼 테스트 코드 리뷰받기 테스트 코드도 코드다 오늘 개발팀 코드 리뷰에 참석했다. 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개월 걸렸지만 이제는 당연하다. 코드는 코드니까.

API 자동화 테스트: UI 테스트보다 빠르고 안정적이다

API 자동화 테스트: UI 테스트보다 빠르고 안정적이다

API 자동화 테스트: UI 테스트보다 빠르고 안정적이다 E2E 테스트가 또 깨졌다 월요일 아침 10시. 출근하자마자 슬랙 알림 30개. "E2E 테스트 실패했어요." "CI 막혔는데요?" "배포 못 하나요?" 밤새 돌린 자동화 테스트. 50개 중 12개 실패. 로그 확인했다. 버튼 클릭 타임아웃. 페이지 로딩 느림. 셀레니움 드라이버 오류. 진짜 버그는 하나도 없다. E2E 테스트의 현실이다. Flaky 테스트. 불안정한 테스트. 실행할 때마다 결과가 다르다. 2년 전 내가 짠 UI 자동화 테스트. 커버리지 80% 목표로 만들었다. 지금은 유지보수가 더 큰 일이다. 개발자가 CSS 클래스 이름 바꾸면 테스트 10개 깨진다. 애니메이션 추가하면 대기 시간 조정해야 한다. 브라우저 업데이트되면 드라이버도 업데이트. 테스트 실행 시간도 문제다. 50개 테스트에 40분. CI에서 돌리면 배포가 느려진다. "이거 자동화 의미 있나요?" 개발팀장이 물었다. 할 말이 없었다.API 테스트로 갈아탔다 3개월 전. 테스트 전략을 바꿨다. E2E는 최소화. 핵심 시나리오만 10개. 나머지는 API 테스트로 대체했다. 테스트 피라미드. 이론으로만 알던 걸 실전에 적용했다. /\ UI (E2E) - 10개 / \ / API \ API - 150개 /______\ / Unit \ Unit - 500개 /__________\API 테스트가 중간 계층이다. 백엔드 로직 검증. UI 없이 빠르게. requests 라이브러리. Python으로 간단하다. import requestsresponse = requests.get('https://api.example.com/users/1') assert response.status_code == 200 assert response.json()['name'] == '홍길동'이게 전부다. 브라우저 띄울 필요 없다. 셀레니움 설치 안 해도 된다. 처음엔 개발자들이 의아해했다. "유저는 API 안 보는데요?" "UI 테스트가 더 실제랑 비슷하지 않나요?" 맞는 말이다. 하지만 목적이 다르다. E2E는 사용자 관점. 전체 플로우 확인. API 테스트는 비즈니스 로직 검증. 백엔드 동작 확인. 둘 다 필요하다. 비율이 문제다. 전에는 E2E 50개, API 20개. 지금은 E2E 10개, API 150개. 결과는 확실했다.속도가 다르다 API 테스트는 빠르다. E2E 1개 실행: 평균 48초 API 테스트 1개 실행: 평균 0.3초 160배 차이. 150개 API 테스트 실행 시간: 45초 전체 테스트 스위트 (E2E 10개 + API 150개): 9분 전에는 40분 걸렸다. 4배 빨라졌다. CI 파이프라인이 막히지 않는다. 개발자들이 PR 올리고 10분 안에 결과 본다. 배포 속도도 올랐다. 하루 3번 배포에서 10번으로. 속도만 빠른 게 아니다. 안정성도 다르다. E2E 테스트 성공률: 76% API 테스트 성공률: 99.2% 실패 원인도 명확하다. API 테스트가 실패하면 진짜 버그다.200이어야 하는데 500 JSON 필드 누락 비즈니스 로직 오류UI 타임아웃 같은 거 없다. 네트워크는 가끔 느리지만 재시도하면 된다. import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retrysession = requests.Session() retry = Retry(total=3, backoff_factor=0.5) adapter = HTTPAdapter(max_retries=retry) session.mount('https://', adapter)response = session.get('https://api.example.com/users')재시도 로직 3줄이면 된다. 셀레니움 암묵적 대기보다 확실하다. 개발자들 반응이 좋아졌다. "테스트 실패 알림 오면 진짜 봐야 할 것만 남았어요." "CI 빨라져서 피드백이 빠르네요." 내가 원한 게 이거다. 신뢰받는 자동화. 시나리오 커버리지 API 테스트로 뭘 검증하나. 우리 서비스는 전자상거래 플랫폼이다. 핵심 기능들. 1. 인증/인가 def test_로그인_성공(): response = requests.post( f'{BASE_URL}/auth/login', json={'email': 'test@test.com', 'password': 'password123'} ) assert response.status_code == 200 assert 'access_token' in response.json()def test_잘못된_비밀번호(): response = requests.post( f'{BASE_URL}/auth/login', json={'email': 'test@test.com', 'password': 'wrong'} ) assert response.status_code == 401 assert response.json()['error'] == 'Invalid credentials'2. 상품 조회 def test_상품_목록_조회(): response = requests.get(f'{BASE_URL}/products') assert response.status_code == 200 products = response.json() assert len(products) > 0 assert 'id' in products[0] assert 'name' in products[0] assert 'price' in products[0]def test_상품_상세_조회(): response = requests.get(f'{BASE_URL}/products/123') assert response.status_code == 200 product = response.json() assert product['id'] == 123 assert product['price'] > 03. 장바구니 def test_장바구니_추가(): token = get_auth_token() response = requests.post( f'{BASE_URL}/cart', headers={'Authorization': f'Bearer {token}'}, json={'product_id': 123, 'quantity': 2} ) assert response.status_code == 201def test_장바구니_조회(): token = get_auth_token() response = requests.get( f'{BASE_URL}/cart', headers={'Authorization': f'Bearer {token}'} ) assert response.status_code == 200 cart = response.json() assert cart['total_price'] > 04. 주문 def test_주문_생성(): token = get_auth_token() response = requests.post( f'{BASE_URL}/orders', headers={'Authorization': f'Bearer {token}'}, json={ 'items': [{'product_id': 123, 'quantity': 1}], 'address': '서울시 강남구', 'payment_method': 'card' } ) assert response.status_code == 201 order = response.json() assert order['status'] == 'pending' assert order['total_amount'] > 0이런 테스트들. 비즈니스 로직 검증. UI에서 하면 어떻게 되나.로그인 페이지 접속 (5초) 아이디/비밀번호 입력 (3초) 로그인 버튼 클릭 (2초) 메인 페이지 로딩 (4초) 상품 검색 (6초) 상품 클릭 (3초) 상세 페이지 로딩 (4초) 장바구니 추가 버튼 클릭 (2초) 장바구니 페이지 이동 (3초) 주문하기 버튼 클릭 (2초) 주문 정보 입력 (10초) 결제 버튼 클릭 (3초)총 47초. API로 하면 1.2초. 게다가 UI 테스트는 앞 단계가 실패하면 다음 검증을 못 한다. 로그인 실패하면 주문 테스트도 못 돌린다. API 테스트는 독립적이다. 각 엔드포인트 따로 검증.실전 구조 우리 API 테스트 구조. tests/ ├── api/ │ ├── auth/ │ │ ├── test_login.py │ │ ├── test_signup.py │ │ └── test_token.py │ ├── products/ │ │ ├── test_list.py │ │ ├── test_detail.py │ │ └── test_search.py │ ├── cart/ │ │ ├── test_add.py │ │ ├── test_update.py │ │ └── test_remove.py │ └── orders/ │ ├── test_create.py │ ├── test_list.py │ └── test_cancel.py ├── fixtures/ │ ├── auth.py │ └── data.py └── utils/ ├── api_client.py └── helpers.pyfixtures는 pytest fixture다. 재사용 가능한 설정. # fixtures/auth.py import pytest import requests@pytest.fixture def api_client(): return requests.Session()@pytest.fixture def auth_token(api_client): response = api_client.post( f'{BASE_URL}/auth/login', json={'email': 'test@test.com', 'password': 'test123'} ) return response.json()['access_token']@pytest.fixture def authorized_client(api_client, auth_token): api_client.headers.update({'Authorization': f'Bearer {auth_token}'}) return api_client이렇게 만들면 테스트가 간단해진다. def test_장바구니_조회(authorized_client): response = authorized_client.get(f'{BASE_URL}/cart') assert response.status_code == 200인증 로직 반복 안 해도 된다. utils는 공통 함수들. # utils/api_client.py class APIClient: def __init__(self, base_url): self.base_url = base_url self.session = requests.Session() def login(self, email, password): response = self.session.post( f'{self.base_url}/auth/login', json={'email': email, 'password': password} ) if response.status_code == 200: token = response.json()['access_token'] self.session.headers.update({'Authorization': f'Bearer {token}'}) return response def get(self, endpoint): return self.session.get(f'{self.base_url}{endpoint}') def post(self, endpoint, data): return self.session.post(f'{self.base_url}{endpoint}', json=data)이렇게 래핑하면 테스트가 더 읽기 쉽다. def test_주문_생성(): client = APIClient(BASE_URL) client.login('test@test.com', 'test123') response = client.post('/orders', { 'items': [{'product_id': 123, 'quantity': 1}], 'address': '서울시 강남구' }) assert response.status_code == 201중복 코드가 줄어든다. 데이터 관리 API 테스트의 어려움. 테스트 데이터. DB에 있는 데이터에 의존한다. 테스트마다 상태가 달라질 수 있다. 우리는 두 가지 방식을 쓴다. 1. 테스트 DB 초기화 각 테스트 전에 DB 리셋. @pytest.fixture(autouse=True) def reset_database(): # 테스트 전에 DB 초기화 db.reset() db.seed_test_data() yield # 테스트 후에 정리 db.cleanup()장점: 깨끗한 상태. 예측 가능. 단점: 느리다. DB 작업이 많으면 시간 걸림. 2. 동적 데이터 생성 테스트마다 고유 데이터 만들기. import uuiddef test_회원가입(): unique_email = f'test_{uuid.uuid4()}@test.com' response = requests.post( f'{BASE_URL}/auth/signup', json={ 'email': unique_email, 'password': 'test123', 'name': '테스터' } ) assert response.status_code == 201장점: 빠르다. 병렬 실행 가능. 단점: 데이터 쌓임. 정리 필요. 우리는 섞어서 쓴다. 읽기 작업은 시드 데이터. 쓰기 작업은 동적 생성. 그리고 테스트 후 정리. created_resources = []def test_상품_생성(): response = requests.post(f'{BASE_URL}/products', json={'name': '테스트상품'}) product_id = response.json()['id'] created_resources.append(('products', product_id)) assert response.status_code == 201@pytest.fixture(scope='session', autouse=True) def cleanup_resources(): yield # 모든 테스트 끝난 후 for resource_type, resource_id in created_resources: requests.delete(f'{BASE_URL}/{resource_type}/{resource_id}')완벽하진 않다. 하지만 관리 가능한 수준. 실패 시 디버깅 API 테스트가 실패하면 어떻게 하나. 로그가 중요하다. import logginglogging.basicConfig(level=logging.DEBUG)def test_주문_생성(): response = requests.post(f'{BASE_URL}/orders', json={'items': []}) logging.info(f'Request URL: {response.request.url}') logging.info(f'Request Body: {response.request.body}') logging.info(f'Response Status: {response.status_code}') logging.info(f'Response Body: {response.text}') assert response.status_code == 201실패하면 전체 요청/응답이 보인다. pytest는 실패한 테스트만 로그 보여준다. 성공하면 조용. 더 자세히 보려면 pytest 플러그인. # conftest.py import pytest@pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == 'call' and report.failed: # 실패한 테스트 정보 저장 if hasattr(item, 'funcargs'): for name, value in item.funcargs.items(): if hasattr(value, 'last_response'): print(f'\n마지막 응답: {value.last_response.text}')CI에서 돌릴 때는 Allure 리포트. pytest --alluredir=./allure-results allure serve ./allure-results예쁜 HTML 리포트. 요청/응답 전부. 스크린샷 대신 JSON. 디버깅이 E2E보다 쉽다. 브라우저 열 필요 없다. 로그만 보면 된다. 한계도 있다 API 테스트가 만능은 아니다. 못 잡는 버그들. 1. UI 버그버튼이 안 보임 CSS 깨짐 모바일 레이아웃 오류API는 정상이어도 화면은 문제일 수 있다. 2. 통합 이슈API는 각각 정상 하지만 함께 쓰면 문제예: 장바구니 API 정상. 결제 API 정상. 그런데 장바구니→결제 플로우에서 세션 유지 안 됨. 3. 타이밍 이슈비동기 처리 웹소켓 통신 실시간 업데이트API 테스트는 요청/응답만 본다. 실시간 상태 변화는 못 본다. 4. 브라우저 호환성Chrome은 되는데 Safari는 안 됨 이건 API 문제가 아니다그래서 E2E를 완전히 없앨 순 없다. 우리 전략.API 테스트: 비즈니스 로직, 백엔드 검증 E2E 테스트: 핵심 사용자 플로우 10개 시각 테스트: UI 변경 감지 (Percy 같은 툴) 모니터링: 실제 사용자 오류 추적 (Sentry)계층별로 역할 분담. CI 파이프라인 통합 Jenkins 파이프라인에 통합했다. pipeline { agent any stages { stage('Unit Tests') { steps { sh 'pytest tests/unit -v' } } stage('API Tests') { steps { sh 'pytest tests/api -v --junit-xml=results.xml' } post { always { junit 'results.xml' } } } stage('E2E Tests') { when { branch 'main' } steps { sh 'pytest tests/e2e -v' } } } }Unit → API → E2E 순서. API 실패하면 E2E 안 돌린다. 시간 절약. PR에서는 API까지만. E2E는 메인 브랜치에만. 병렬 실행도 한다. stage('API Tests') { parallel { stage('Auth Tests') { steps { sh 'pytest tests/api/auth -v' } } stage('Products Tests') { steps { sh 'pytest tests/api/products -v' } } stage('Orders Tests') { steps { sh 'pytest tests/api/orders -v' } } } }독립적인 테스트들이니까 가능하다. 실행 시간이 반으로 줄었다. 팀 설득 과정 처음엔 반대가 있었다. 개발팀장: "유저는 API 안 보잖아요." 프론트 개발자: "UI 테스트가 더 중요한 거 아닌가요?" QA 후배: "저는 셀레니움만 배웠는데..." 설득 방법.데이터로 보여줬다.E2E 실패율 24% API 실패율 0.8% 실행 시간 비교파일럿 프로젝트.인증 API만 먼저 자동화 2주 후 결과 공유 "이거 괜찮은데요?"점진적 적용.E2E 줄이고 API 늘리기 한 번에 바꾸지 않음교육.requests 라이브러리 세션 API 테스트 작성 가이드 페어 프로그래밍지금은 다들 만족한다. 개발자: "테스트 신뢰도 올라서 좋아요." QA 후배: "생각보다 쉽네요." 팀장: "배포 속도 빨라졌어요." 변화는 서서히. 유지보수 API 테스트도 코드다. 유지보수 필요하다. 관리 포인트. 1. 엔드포인트 변경 백엔드가 API 수정하면 테스트도 수정. # 전: /api/v1/products # 후: /api/v2/products# config.py에서 버전 관리 API_VERSION = 'v2' BASE_URL = f'https://api.example.com/api/{API_VERSION}'한 곳만 바꾸면 됨. 2. 응답 포맷 변경 JSON 구조 바뀌면 assertion 수정. # 전: {'user': {'name': '홍길동'}} # 후: {'data': {'user': {'name': '홍길동'}}}# 헬퍼 함수로 추상화 def get_user_name(response): return response.json()['data']['user']['name']assert get_user_name(response) == '홍길동'3. 인증 방식 변경 JWT에서 OAuth로 바꾸면 fixture 수정. @pytest.fixture def auth_token(): # JWT 방식 # response = login(email, password) # return response.json()['token'] # OAuth 방식 oauth = OAuth2Session(client_id) token = oauth.fetch_token(token_url) return token['access_token']4. 테스트 데이터 정리 쌓이는 데이터 주기적으로 삭제. # 매일 새벽 3시 0 3 * * * python scripts/cleanup_test_data.py월 1회 리뷰. 안 쓰는 테스트 삭제. 중복 테스트 합치기. 성과 숫자로 보는 변화. 전 (E2E 중심)테스트 개수: 50개 실행 시간: 40분 성공률: 76% 배포 횟수: 하루 3회 버그 탈출: 월 8건후 (API 중심)테스트 개수: 160개 (E2E 10 + API 150) 실행 시간: 9분 성공률: 98.5% 배포 횟수: 하루 10회 버그 탈출: 월 2건커버리지는 올리고 시간은 줄었다. 비용도 줄었다. CI 실행 시간 줄어서 인프라 비용 30% 감소. 개발자 만족도도 올랐다. 설문조사 결과. "테스트 신뢰한다": 45% → 89% "CI가 빠르다": 32% → 91% "테스트가 도움된다": 51% → 87% 내가 가장 만족하는 건. 야근이 줄었다. 전에는 E2E 실패 원인 찾느라 밤 늦게까지. 지금은 API 실패하면 5분 안에 원인 파악. Flaky 테스트 스트레스가 없어졌다. 추천 사항 API 테스트 시작하려면. 1. 작게 시작 한 번에 다 바꾸지 마라. 한 모듈부터. 인증 API 5개 테스트. 2주 돌려보기. 2. 개발자와 협업 API 문서 같이 보기. 엔드포인트 이해하기. Swagger 있으면 더 좋다. 3. 도구는 단순하게 requests만으로 충분하다. 복잡한 프레임워크 필요 없다. pytest + requests + 약간의 fixture. 4. CI 먼저 통합 로컬에서만 돌리지 마라. CI에서 자동 실행. 빠른 피드백이 핵심. 5. 문서화 API 테스트 작성 가이드 만들기. "이런 케이스는 이렇게" 예시. 6. 리팩토링 시간 확보 테스트도 코드다. 정리 시간 필요. 월 1회 리팩토링 데이.E2E는 느리고 불안정하다. API 테스트는 빠르고 안정적이다. 완전히 대체는 못 한다. 하지만 대부분을 대체할 수 있다. 테스트 피라미드 중간 계층. 여기가 가장 효율적이다. 셀레니움 스트레스에서 해방됐다. requests 한 줄이면 된다. 고민하지 말고 시작하자. 작은 테스트 하나부터.

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"여기 다 있어요. 주석이 곧 문서입니다." 신입이 바로 이해했다. 어떤 케이스를 테스트하는지 코드만 봐도 안다. 별도 문서가 필요 없다. 코드와 문서가 동기화된다.파라미터화는 단순히 코드를 줄이는 게 아니다. 테스트를 데이터로 관리하는 사고방식이다. 유지보수가 쉽고 확장이 편하다. 한 번 익히면 돌아갈 수 없다.

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

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

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