Showing Posts From

테스트

테스트 데이터 관리: 공유 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 쓰면서 병렬 테스트 돌리는 거, 생각보다 복잡하다. 데이터 격리만 잘해도 절반은 해결되는데.

야근하며 수정한 테스트: 내일 아침 더 꼬여 있다

야근하며 수정한 테스트: 내일 아침 더 꼬여 있다

오후 6시, 급한 불 배포가 내일이다. PM이 슬랙에 멘션을 날렸다. "J님, 새 결제 기능 자동화 테스트 내일 아침까지 가능할까요?" 가능하냐고? 불가능하다. 하지만 대답은 정해져 있다. "네, 해보겠습니다." 정상적으로 하면 이틀 걸린다. API 스펙 확인하고, 테스트 시나리오 짜고, Page Object 설계하고, 스크립트 작성하고, 리뷰받고. 그게 정석이다. 지금 시간은 6시 반. 퇴근 시간 지났다. 옆자리 개발자들 하나둘 나간다. "먼저 갈게요." "네, 수고하세요." 모니터 3개를 켰다. 왼쪽엔 API 문서, 가운데엔 IDE, 오른쪽엔 테스트 실행 화면. 커피 한 잔 더 탔다. 네 번째다.급하게 짜는 코드의 특징 8시다. 일단 돌아가는 걸 만들었다. def test_payment(): driver.get("https://...") driver.find_element_by_id("btn_pay").click() time.sleep(5) # 일단 5초 기다림 assert "success" in driver.page_source보기 싫다. 하드코딩 범벅이다. sleep으로 때웠다. Page Object도 없다. 그냥 element 직접 찾는다. 하지만 돌아간다. 지금은 그게 중요하다. 다음 시나리오. 카드 결제 실패 케이스. def test_payment_fail(): driver.get("https://...") driver.find_element_by_id("card_num").send_keys("1234") # 잘못된 카드 driver.find_element_by_id("btn_pay").click() time.sleep(5) assert "실패" in driver.page_source복붙했다. 앞에 코드 거의 똑같다. URL도 하드코딩, 셀렉터도 하드코딩. fixture도 안 만들었다. "나중에 리팩토링하면 되지." 이 말을 몇 번 했는지 모른다. 경력 7년차가 이러면 안 되는데. 9시 반. 테스트 케이스 5개 짰다. 전부 이런 식이다. 코드 중복 천지. 하드코딩 천국. sleep 축제. 돌려봤다. 3개 성공, 2개 실패. 실패한 이유를 봤다. 타이밍 이슈다. sleep(5)가 부족했다. 10으로 늘렸다. 다시 돌렸다. 4개 성공, 1개 실패. 또 타이밍이다. sleep(15)로 늘렸다. 전부 통과했다. "좋아, 일단 된다." 커밋했다. 메시지는 "add payment test cases". 상세 설명 없다. 급하니까.다음날 아침의 충격 출근했다. 10시 10분. 늦었다. 어젯밤 12시에 퇴근했다. 슬랙을 켰다. 알림 7개. 전부 Jenkins 실패 알림이다. "어? 어제 다 통과했는데?" CI 로그를 봤다. 전부 타임아웃이다. selenium.common.exceptions.TimeoutException Element not found: btn_pay아, 맞다. 하드코딩했지. 개발자가 어젯밤에 버튼 ID를 바꿨다. btn_pay에서 button_payment로. 슬랙에 개발자 메시지가 있다. "결제 버튼 컴포넌트 리팩토링했습니다. UI 변경 없어요." UI는 안 바뀌었다. 하지만 ID는 바뀌었다. 그걸 하드코딩했으니 깨진다. 수정했다. 5군데. 다시 푸시. 다시 실행. 또 실패. 이번엔 다른 에러. AssertionError: 'success' not found in page source페이지 구조가 바뀌었다. 성공 메시지가 모달로 뜬다. page_source엔 안 보인다. 수정했다. explicit wait 추가. 모달 찾는 로직 추가. 그런데 다른 테스트도 깨졌다. 전부 같은 패턴으로 짰으니까. 10시 반. 벌써 30분 날렸다. PM이 슬랙에 물었다. "J님 테스트 통과했나요? 배포 11시예요." "조금만요, 거의 다 됐습니다." 거짓말이다. 아직 멀었다.기술 부채의 이자 점심시간이다. 배포는 연기됐다. 내 때문이다. 테스트는 겨우 통과시켰다. 하지만 더 꼬였다. 어젯밤 코드 위에 오늘 아침 땜질을 했다. 나쁜 코드 위에 더 나쁜 코드. def test_payment(): driver.get("https://...") time.sleep(3) # 페이지 로딩 btn = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "button_payment")) ) btn.click() time.sleep(5) # 결제 처리 try: modal = driver.find_element_by_class_name("modal_success") assert "success" in modal.text except: assert "success" in driver.page_source # fallbacksleep이랑 explicit wait이 섞였다. try-except로 땜질했다. fallback 로직도 이상하다. 이게 5개 테스트에 복붙되어 있다. 팀장이 코드 리뷰를 달았다. "J님, 이거 Page Object 패턴 안 쓴 이유가 있나요?" "급해서 일단 돌아가게 만들었습니다. 리팩토링할게요." "배포 끝나고 꼭 해주세요. 이렇게 가면 유지보수 힘듭니다." 안다. 나도 안다. 하지만 리팩토링할 시간이 있을까? 다음주에 또 급한 기능이 온다. 또 급하게 짤 것이다. 또 부채가 쌓일 것이다. 악순환의 시작 2주 지났다. 결제 테스트는 그대로다. 리팩토링 할 시간이 없었다. 새 기능 테스트 짜느라. 그것도 급하게 짰다. 같은 패턴으로. 이제 급한 코드가 10개 파일이다. CI 실행시간이 20분에서 35분으로 늘었다. sleep 때문이다. Flaky 테스트가 생겼다. 타이밍 이슈로 가끔 실패한다. 개발자들이 불평한다. "테스트 왜 이렇게 느려요?" "가끔 이유 없이 실패하던데, 테스트 문제 아닌가요?" 변명할 수 없다. 내 코드가 문제다. 새로 온 QA 후배가 내 코드를 봤다. "선배님, 이 테스트 왜 이렇게 짜셨어요? Page Object 안 쓰셨네요?" "응, 급해서... 나중에 고칠 거야." "그럼 제가 새로 짤 테스트도 이렇게 짜면 돼요?" "아니, 너는 제대로 짜. 이건... 나쁜 예시야." 후배한테 나쁜 예시를 보여주고 있다. 부채의 이자는 복리다 한 달 지났다. 급한 코드가 20개 파일이 됐다. 이제 신규 기능 하나 추가하려면 기존 코드를 봐야 한다. 복붙할 게 많으니까. 그러다 나쁜 패턴도 같이 복붙된다. API가 바뀌면 20군데를 고쳐야 한다. 하드코딩했으니까. UI 컴포넌트가 바뀌면 테스트 10개가 깨진다. 셀렉터를 직접 박았으니까. CI 실행시간은 50분이다. sleep 총합이 5분이 넘는다. 테스트 커버리지는 올랐다. 65%. 좋아 보인다. 하지만 품질은 떨어졌다. 테스트를 믿을 수 없다. 개발자들이 테스트 실패를 무시한다. "저 테스트 원래 잘 깨지잖아요. 그냥 재실행하면 돼요." 틀린 말이 아니다. PM이 물었다. "테스트 자동화 많이 했는데, 버그는 왜 여전히 나와요?" 대답할 수 없었다. 리팩토링의 두려움 주말이다. 집에서 쉬고 있다. 리팩토링을 해야 한다는 생각이 머리를 떠나지 않는다. 계획을 세웠다.Page Object 패턴 적용 공통 fixture 만들기 sleep 전부 explicit wait로 교체 하드코딩된 셀렉터 전부 상수로 분리 중복 코드 제거최소 3일은 걸린다. 풀타임으로. 하지만 3일을 쓸 수 있을까? 그 사이에 버그가 나면? 긴급 패치가 들어가면? 새 기능이 또 급하게 오면? 리팩토링 도중에 기존 테스트는 돌아가지 않는다. CI가 깨진다. 팀장한테 말해야 한다. "3일간 다른 일 못 합니다. 리팩토링합니다." 승인이 날까? 더 큰 두려움이 있다. 리팩토링 하다가 기존 테스트를 깨뜨리면 어떡하지? 지금 코드가 나쁘지만, 어쨌든 돌아간다. 고치다가 더 망가뜨리면? "그냥 새로 짜는 게 나을까?" 하지만 새로 짜는 것도 3일이다. 똑같다. 결국 아무것도 안 했다. 월요일이 됐다. 작은 리팩토링의 시작 월요일 오전. 회의가 없다. 결심했다. 전체는 못 해도 일부는 한다. 가장 자주 깨지는 테스트 파일 하나만. test_payment.py. 2시간 걸렸다. Page Object 만들었다. class PaymentPage: BTN_PAYMENT = (By.ID, "button_payment") MODAL_SUCCESS = (By.CLASS_NAME, "modal_success") def __init__(self, driver): self.driver = driver def click_payment(self): element = WebDriverWait(self.driver, 10).until( EC.element_to_be_clickable(self.BTN_PAYMENT) ) element.click() def get_result_message(self): modal = WebDriverWait(self.driver, 10).until( EC.visibility_of_element_located(self.MODAL_SUCCESS) ) return modal.text테스트 코드가 깔끔해졌다. def test_payment_success(payment_page): payment_page.click_payment() result = payment_page.get_result_message() assert "success" in resultsleep이 사라졌다. 중복도 사라졌다. 읽기 쉽다. 돌려봤다. 통과했다. CI에도 통과했다. 실행 시간이 3분 줄었다. 기분이 좋다. 오후에 또 다른 파일 하나. test_cart.py. 같은 방식으로. 2시간 반 걸렸다. 이번엔 조금 빨랐다. 패턴을 알았으니까. 하루에 파일 2개씩 리팩토링한다. 10일이면 20개 끝난다. 점진적 개선 2주 지났다. 급한 코드의 절반을 고쳤다. 완벽하지 않다. 아직 개선할 부분이 많다. 하지만 확실히 나아졌다. CI 실행시간이 35분으로 줄었다. Flaky 테스트가 5개에서 2개로 줄었다. 코드 리뷰에서 지적받는 횟수가 줄었다. 후배가 말했다. "선배님, 이번 코드는 이해하기 쉬워요. 이렇게 짜면 되는 거죠?" "응, 이게 맞아." 개발자가 슬랙에 썼다. "테스트 빨라졌네요. 뭐 하셨어요?" "리팩토링 좀 했습니다." "좋네요. ㅎㅎ" 작은 칭찬이지만 기분 좋다. 팀장이 1on1에서 말했다. "테스트 코드 품질 올라간 거 보여요. 꾸준히 하고 있죠?" "네, 조금씩 하고 있습니다." "급하다고 대충 짜는 거, 나도 이해해요. 하지만 결국 돌아오더라고요." "맞아요. 배웠습니다." "이번 분기 평가에 반영할게요. 수고하고 있어요." 배운 것들 급한 코드는 빚이다. 언젠가 갚아야 한다. 이자는 복리다. 급한 코드 위에 급한 코드가 쌓인다. 한 번에 갚을 순 없다. 파산한다. 조금씩 갚는 수밖에 없다. "나중에 리팩토링하면 돼"는 거짓말이다. 나중은 안 온다. 더 급한 일만 온다. 그래서 매일 조금씩 해야 한다. 출근해서 1시간. 파일 하나. 함수 몇 개. 그럼 부채가 줄어든다. 천천히지만 확실하게. 완벽한 코드를 짤 순 없다. 급할 때도 있다. 하지만 부채를 인정하고 관리해야 한다. 급하게 짰으면 주석을 남긴다. "TODO: refactor to Page Object". 급하게 짰으면 이슈를 만든다. "기술 부채: test_payment 리팩토링". 급하게 짰으면 다음날 1시간 투자한다. 조금이라도 개선한다. 그게 쌓이면 달라진다. 지금 내 코드는 한 달 전보다 낫다. 완벽하지 않다. 아직 급한 코드가 10개 남았다. 하지만 매일 조금씩 나아진다. 그걸로 충분하다.어젯밤 야근해서 짠 코드. 오늘 아침엔 더 꼬여 있다. 그게 부채의 이자다. 하지만 매일 조금씩 갚으면, 언젠가 0이 된다. 그걸 믿고 오늘도 리팩토링 한다.

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에 모바일 지원이 실험 단계에 있다. 아직 프로덕션 레디는 아니다. 당분간은 분

테스트 리포트를 숫자가 아닌 스토리로 전달하기

테스트 리포트를 숫자가 아닌 스토리로 전달하기

테스트 리포트를 숫자가 아닌 스토리로 전달하기 7시 30분, 리포트 정리 시작 매일 저녁 7시. 내 업무 루틴은 리포트 정리로 끝난다. Jenkins에서 떨어진 테스트 결과. Pytest 리포트. Allure 대시보드. 숫자만 잔뜩이다. Pass 2,847개. Fail 23개. Skip 15개. 이걸 그대로 공유하면? 아무도 안 본다. 작년까지 나도 그랬다. 엑셀에 숫자 복붙. 컬러 코딩. 메일 발송. 다음 날 개발팀장이 물었다. "그래서 릴리즈 해도 돼요?" 멘탈이 나갔다. 2,847개 테스트를 통과했는데. 이걸 어떻게 설명하지. 그때부터 바꿨다. 리포트는 숫자가 아니라 이야기다.CTO가 물어본 질문 리포트 방식을 바꾸게 된 계기. 3개월 전 월례 회의. CTO가 내 리포트를 보더니 물었다. "테스트 커버리지 85%면 좋은 건가요? 릴리즈 해도 되나요?" 순간 막혔다. 기술적으론 좋은 수치다. 업계 평균도 70% 정도니까. 근데 "좋다"고 말할 수 없었다. 결제 모듈 테스트가 3개 실패 중이었거든. 85%라는 숫자는 의미 없었다. 나머지 15%가 어디냐가 중요했다. 그날 깨달았다. 임원진은 QA 전문가가 아니다. 숫자보다 리스크를 알고 싶어 한다. "릴리즈해도 안전한가"가 궁금한 거다. 다음 회의부터 접근을 바꿨다. "결제 모듈 테스트 3개 실패. 릴리즈 블로킹 이슈. 다른 건 문제없음." CTO 표정이 달라졌다. "결제는 고쳐야죠. 나머진 배포 가능?" 드디어 대화가 됐다. 숫자 대신 컨텍스트 리포트 구조를 완전히 뜯어고쳤다. Before:전체 테스트: 2,885개 Pass: 2,847개 (98.7%) Fail: 23개 (0.8%) Skip: 15개 (0.5%)After:릴리즈 블로킹: 3개 (결제 API 타임아웃) 주의 필요: 5개 (UI 깨짐, UX 영향) 알려진 이슈: 15개 (백로그 등록됨) 안전: 2,847개 기능 검증 완료같은 데이터다. 전달 방식만 바뀌었다. 근데 반응이 180도 달라졌다. 개발팀장: "결제는 내일 아침까지 고칠게요." PM: "UI 깨진 건 어느 화면이에요? 급한가요?" CTO: "알려진 이슈 15개는 언제 해결 예정?" 구체적인 액션이 나왔다. 이게 진짜 리포트다.시각화는 도구일 뿐 처음엔 시각화에 집착했다. Grafana 대시보드 만들었다. 그래프 10개. 실시간 업데이트. 컬러풀하고 멋있었다. 근데 아무도 안 봤다. 문제는 간단했다. 너무 많았다. 임원진은 대시보드 10분 볼 시간이 없다. 1분 안에 핵심만 알고 싶어 한다. 지금은 3가지만 보여준다. 1. 신호등 (Traffic Light)빨강: 릴리즈 블로킹. 절대 배포 금지. 노랑: 주의 필요. 비즈니스 판단 필요. 초록: 안전. 배포 가능.한눈에 본다. 오늘이 빨강인지 초록인지. 2. 트렌드 (Trend)최근 2주 테스트 실패율 그래프 갑자기 튄 날 있으면 설명 추가 "10일 - API 배포로 인한 일시적 실패"패턴을 본다. 점점 나아지는지 악화되는지. 3. 핫스팟 (Hotspot)가장 자주 실패하는 모듈 Top 3 테스트 불안정한 기능 표시 "로그인 모듈 - 이번 주 4번 실패"리스크 영역을 본다. 어디에 집중해야 하는지. 이 3개면 충분하다. 나머지는 디테일 페이지에 넣어뒀다. 궁금하면 클릭해서 보면 된다. 대부분은 안 본다. 그래도 된다. 이야기 구조로 전달 리포트를 이야기처럼 쓴다. 기승전결 구조: 기(상황): "오늘 2,885개 테스트 실행. 밤새 4시간 걸렸습니다." 승(문제): "결제 API 테스트 3개 실패. 타임아웃 에러. 원인은 DB 쿼리 성능 이슈." 전(영향): "결제 플로우 전체에 영향. 신용카드/계좌이체 모두 영향권. 릴리즈 블로킹." 결(해결): "백엔드팀 인덱스 추가 중. 내일 오전 재테스트 예정. 그 외 영역은 배포 가능." 누구나 이해한다. 기술 배경 없어도. 실제 예시 (지난주 금요일 리포트): 제목: [빨강] 결제 모듈 블로킹, 그 외 배포 가능 "오늘 릴리즈 준비 테스트 완료했습니다. 좋은 소식: 신규 검색 기능, 마이페이지 개편 모두 통과. 2주간 준비한 기능들 문제없습니다. 나쁜 소식: 결제 API 성능 이슈 발견. 1,000건 이상 주문 시 타임아웃. 실제 운영 데이터로 테스트하면서 잡혔습니다. 영향도: 블랙프라이데이 대비 필수. 대량 주문 처리 불가능. 해결: 백엔드팀 DB 최적화 중. 월요일 오전 재검증. 결론: 검색/마이페이지는 이번 주 배포 가능. 결제는 다음 주." CTO 답장: "결제는 다음 주 가능할까요? 블프 전까진 필수." 즉시 답했다: "월요일 오후면 확실히 결론 드릴게요." 이게 소통이다.인사이트는 숨어있다 리포트는 결과만 전달하는 게 아니다. 데이터 속에서 인사이트를 찾는다. 패턴을 본다. 예시 1 - 특정 시간대 실패: "로그인 테스트가 새벽 3시에만 실패. 조사해보니 이 시간에 DB 백업 돌아감. 백업 중 타임아웃 늘어남. 테스트 타이밍 조정 또는 백업 시간 변경 필요." → 운영 이슈 발견. DevOps팀이 백업 시간 옮김. 예시 2 - 특정 OS 실패: "iOS 테스트만 3주간 실패율 높음. Android는 정상. iOS 14.8 특정 버전 이슈. 애플 버그. 우리가 고칠 수 없음. 사용자한테 OS 업데이트 권장 필요." → CS팀이 공지 올림. 문의 감소. 예시 3 - 특정 개발자 커밋: "A 개발자 PR 후 테스트 실패율 증가 패턴. 악의는 없음. 테스트 코드 작성 습관 부족. 페어 프로그래밍 제안." → 조심스럽게 팀장한테 공유. 개선됨. 인사이트는 액션으로 이어진다. 단순 결과 나열과 차원이 다르다. 템플릿을 만들었다 매일 처음부터 쓸 순 없다. 템플릿 3개 만들었다. 1. 일일 리포트 (Daily)신호등 상태 블로킹 이슈 (있으면) 새로 발견된 버그 내일 테스트 계획5분 안에 작성. Slack에 공유. 2. 주간 리포트 (Weekly)이번 주 하이라이트 트렌드 분석 (좋아짐/나빠짐) 다음 주 리스크 예상 요청 사항20분 작성. 이메일 발송. 3. 릴리즈 리포트 (Release)전체 테스트 커버리지 릴리즈 가능 여부 (Go/No-Go) 알려진 이슈 목록 릴리즈 후 모니터링 계획1시간 작성. 회의에서 발표. 템플릿 있으니 일관성 생겼다. 누가 봐도 같은 구조다. 자동화할 건 자동화 리포트 생성도 반은 자동화했다. Python 스크립트 하나 만들었다. # 내가 만든 리포트 생성기 (간략 버전) def generate_daily_report(): results = parse_test_results() blocking = [t for t in results if t.severity == 'critical' and t.status == 'failed'] warnings = [t for t in results if t.severity == 'high' and t.status == 'failed'] if blocking: signal = "🔴 빨강" elif warnings: signal = "🟡 노랑" else: signal = "🟢 초록" report = f""" [{signal}] {date.today()} 테스트 리포트 블로킹: {len(blocking)}개 {format_test_list(blocking)} 주의: {len(warnings)}개 {format_test_list(warnings)} 안전: {len([t for t in results if t.status == 'passed'])}개 검증 완료 """ return report매일 저녁 7시 자동 실행. 초안을 만들어준다. 내가 할 일은 컨텍스트 추가. "왜 실패했는지", "언제 해결되는지". 시간이 절반으로 줄었다. 예전엔 1시간. 지금은 30분. 질문 받을 준비 리포트 보내면 질문 온다. 준비해둔다. 예상 질문과 답변. 자주 오는 질문: Q: "이번 주 배포 가능?" A: 준비됨. 신호등 색깔 + 조건부 배포 가능 여부. Q: "이 버그 언제 고쳐짐?" A: 담당자 확인 + 예상 일정. 모르면 "확인 후 알려드릴게요" 솔직히. Q: "커버리지 왜 떨어졌어?" A: 신규 기능 추가됨. 분모 늘어남. 테스트 추가 중. 다음 주 회복 예정. Q: "자동화 안 되는 건 어떻게 테스트?" A: 매뉴얼 테스트 계획 있음. 담당자 배정됨. 결과는 금요일 공유. 답 못하는 질문도 있다. 그땐 솔직하게. "확인해보고 알려드릴게요. 30분 드리면 될까요?" 모르는 척 안 한다. 추측도 안 한다. 확인 후 정확히 답한다. 신뢰는 일관성에서 3개월 했더니 달라진 게 있다. 사람들이 내 리포트를 기다린다. "자동화J 리포트 왔어?" "오늘 신호등 뭐야?" "이번 릴리즈 괜찮대?" 예전엔 읽으라고 태그 걸었다. 지금은 먼저 찾아본다. 이유는 간단하다. 일관성. 매일 같은 시간. 같은 형식. 같은 수준의 디테일. 한 번도 빠진 적 없다. 금요일 밤 배포 전에도. 월요일 아침 장애 후에도. 신뢰는 거기서 온다. "자동화J 리포트면 믿을 만해." 가끔 PM이 의사결정 회의에 내 리포트 캡처해서 쓴다. "QA팀 판단으론 릴리즈 가능합니다." 뿌듯하다. 리포트가 의사결정 자료가 됐다. 실패도 공유한다 좋은 것만 보고하지 않는다. 테스트 놓친 것도 쓴다. 자동화 실패도 쓴다. 지난달 장애: "로그인 장애 발생. 테스트에서 못 잡았습니다. 원인: 타사 OAuth 서버 다운. 우리 테스트는 Mock 사용. 실제 연동은 검증 안 됨. 개선: 주 1회 실제 OAuth 통합 테스트 추가." 숨기지 않았다. 솔직하게 썼다. CTO 반응: "개선 계획 좋습니다. 다음 주부터 시작하세요." 책망 없었다. 오히려 신뢰 올라갔다. 완벽한 척 안 한다. QA도 사람이다. 다 잡을 순 없다. 중요한 건 배우고 개선하는 것. 팀원도 가르친다 후배 QA 2명 있다. 이들도 리포트 쓴다. 가르쳐줬다. 처음엔 숫자만 나열했다. 당연하다. 나도 그랬으니까. "이 숫자가 말하는 게 뭐야? 릴리즈 해도 돼?" 질문하면서 가르쳤다. 숫자 뒤 컨텍스트를 보라고. 2주 지나니 좋아졌다. 한 달 지나니 제법이다. 이제 나 없어도 리포트 돈다. 휴가 갈 수 있다. 데이터는 이야기를 원한다 숫자는 사실이다. 이야기는 의미다. 2,847개 통과는 사실. "결제 빼고 다 안전합니다"는 의미. 의미를 전달해야 움직인다. 액션이 나온다. 리포트는 보고서가 아니다. 소통 도구다.오늘도 7시 30분. 리포트 쓴다. 신호등은 초록. 내일 배포 간다.

테스트 실패 분석: 버그인가, 스크립트 문제인가 판단하는 법

테스트 실패 분석: 버그인가, 스크립트 문제인가 판단하는 법

테스트 실패 분석: 버그인가, 스크립트 문제인가 판단하는 법 10시 출근, 빨간 불 출근했다. 슬랙에 알림 12개. Jenkins에서 온 거다. "Build #247 Failed - 18 test cases" 커피 먼저 탔다. 보고 나면 점심까지 못 마신다. 경험으로 안다. 모니터 3개 켰다. 왼쪽에 Jenkins, 가운데 로그, 오른쪽에 코드. 이게 내 전투 준비다. 18개 실패. 많은 편이다. 어제 배포 있었나 확인했다. Dev팀이 프론트엔드 UI 수정했다. 아. 첫 느낌으로 70%는 스크립트 문제다. 나머지 30%가 진짜 버그일 확률.로그부터 읽는다 로그 읽기가 제일 중요하다. 근데 로그 해석은 경력이다. 첫 번째 실패 케이스 열었다. ElementNotInteractableException: Element <button id="submit-btn"> is not clickable at point (450, 320). Other element would receive the click: <div class="modal-overlay">이건 명백하다. 모달이 버튼을 가린 거다. 개발자가 모달 로직 바꿨나 보다. 두 번째 케이스. TimeoutException: Message: Expected condition failed: waiting for visibility_of_element_located((By.ID, "user-profile"))타임아웃. 이것도 익숙하다. 두 가지 가능성.페이지 로딩이 느려졌다 (서버 문제) Element ID가 바뀌었다 (UI 변경)세 번째부터는 패턴이 보인다. 전부 같은 에러. NoSuchElementException: Unable to locate element: {"method":"css selector","selector":".btn-primary"}클래스명이 바뀐 거다. .btn-primary를 .primary-button로 바꿨나. 개발자한테 슬랙 날렸다. "어제 버튼 클래스명 바꿨어?" "아 맞다. 디자인 시스템 통일한다고" "..." 이래서 QA가 스트레스받는다. 알려주지도 않는다.스크린샷 체크 로그만으로 부족할 때 스크린샷 본다. Selenium은 실패 시 자동 캡처하게 설정해뒀다. /screenshots/test_login_flow_20240115_0342.png 열었다. 화면에 로딩 스피너가 있다. 아직 페이지가 다 안 떴는데 테스트가 실행된 거다. 이건 스크립트 문제다. Wait 조건이 부족해. 현재 코드 확인했다. driver.find_element(By.ID, "username").send_keys("test@test.com") driver.find_element(By.ID, "password").send_keys("password123") driver.find_element(By.ID, "login-btn").click()바로 찾아서 클릭한다. Wait 없다. 고쳐야 한다. wait = WebDriverWait(driver, 10) username_field = wait.until( EC.element_to_be_clickable((By.ID, "username")) ) username_field.send_keys("test@test.com")이렇게 바꿔야 안정적이다. 다른 스크린샷도 봤다. 결제 페이지 테스트 실패 건. 화면에 에러 메시지가 떠 있다. "Invalid payment method" 이건 진짜 버그다. 로그가 아니라 화면으로 확인됐다. JIRA 티켓 생성했다. "[BUG] Payment fails with valid test credit card" 재현 스텝 써서 Dev팀에 할당. 스크린샷 3장 첨부했다. 개발자가 바로 이해하게. 재현 테스트의 기술 의심스러운 케이스는 직접 돌려본다. 자동화 테스트 결과를 100% 믿으면 안 된다. Flaky test가 있다. 터미널 열고 단일 테스트 실행했다. pytest tests/test_checkout.py::test_guest_checkout -v1차: PASSED 2차: PASSED3차: FAILED 3번 중 1번 실패. 전형적인 Flaky test다. 로그 다시 봤다. 네트워크 타임아웃이 원인이다. API 응답이 가끔 느리다. 서버 문제일 수도 있다. 재현율 체크를 위해 10번 돌렸다. for i in {1..10}; do pytest tests/test_checkout.py::test_guest_checkout; done10번 중 3번 실패. 30% 재현율. 이 정도면 스크립트 문제가 아니다. 서버나 네트워크 이슈다. 백엔드 개발자한테 알렸다. "Checkout API 응답 시간 체크해줄 수 있어? 간헐적으로 타임아웃 나." 답장 왔다. "어제 DB 인덱스 작업했는데 그것 때문일 수도" 역시. 버그 맞다.판단 기준표 4년 하면서 만든 내 기준이다. 스크립트 문제일 확률 높음:NoSuchElementException (90%) ElementNotInteractableException (85%) 같은 에러가 여러 테스트에서 발생 (95%) 최근 UI 배포 후 발생 (90%) 재현율 100% (80%)버그일 확률 높음:AssertionError (70%) 화면에 에러 메시지 표시 (95%) 예전엔 통과했던 테스트 (60%) 재현율 낮음, 간헐적 (40%) 특정 환경에서만 발생 (75%)애매한 경우:TimeoutException (50/50) StaleElementReferenceException (60% 스크립트) Network error (70% 버그) Screenshot이 공백 (디버깅 필요)물론 절대적이지 않다. 맥락을 봐야 한다. 어제 배포 있었나, 환경 설정 바뀌었나, 외부 API 문제인가. 실전 프로세스 내가 실제로 하는 순서다. 1. 슬랙 알림 확인 (30초)Jenkins 빌드 번호 체크 몇 개 실패했나 파악 급한 건지 판단2. Jenkins 대시보드 훑기 (2분)실패 케이스 이름 쭉 본다 패턴 찾는다 (같은 모듈? 같은 에러?) 어느 브랜치인지 확인3. 첫 번째 실패 로그 정독 (5분)에러 타입 확인 스택 트레이스 읽기 실패한 코드 라인 찾기4. 스크린샷 체크 (3분)화면 상태 확인 의도한 화면인지 판단 UI 변경 여부 파악5. 코드 리뷰 (5분)해당 테스트 스크립트 열기 Wait 조건 있나 확인 Selector가 정확한가 체크6. 재현 테스트 (10분)로컬에서 직접 실행 3회 이상 돌려보기 재현율 계산7. 판단 및 액션 (5분)스크립트 문제면 바로 수정 버그면 JIRA 생성 애매하면 개발자와 논의총 30분 정도 걸린다. 18개 실패면 오전 내내 이거다. 자주 속는 케이스들 경험으로 배운 함정들이다. 1. 타임아웃인 줄 알았는데 버그 로딩이 느린 게 아니라, API가 아예 안 돌아오는 거였다. 백엔드 500 에러. Wait을 늘려봤자 소용없다. 에러 로그 끝까지 읽어야 한다. 2. Flaky test인 줄 알았는데 스크립트 문제 가끔 실패하니까 Flaky라고 생각했다. 근데 아니었다. 페이지에 애니메이션이 있었다. 클릭 타이밍을 못 잡은 거다. time.sleep(1) 넣으니까 통과. 근데 이건 나쁜 코드다. EC.element_to_be_clickable로 바꿔야 한다. 3. 로컬에선 되는데 CI에서 실패 환경 차이다. CI 서버가 느리거나, 해상도가 다르거나, 브라우저 버전이 다르거나. Headless 모드 문제일 수도 있다. Docker 컨테이너에서 직접 돌려봐야 안다. 4. 에러 메시지를 믿었다가 낭패 "Element not found" 에러인데, 사실 iframe 안에 있었다. 에러 메시지만 보고 셀렉터 바꿨다가 시간 낭비. HTML 구조 직접 확인해야 한다. 개발자와 소통법 이게 제일 중요하다. 판단 끝나면 전달이다. 스크립트 문제일 때: 슬랙에 조용히 쓴다. "어제 UI 변경 건 때문에 테스트 18개 깨짐. 오늘 중으로 고칠게." 개발자 멘션 안 한다. 내가 처리할 거니까. 버그일 때: JIRA 티켓 먼저 만든다. 슬랙에 링크 공유. "[BUG-1234] 결제 실패 이슈 확인됨. 우선순위 체크 부탁드려." 재현 스텝을 명확히 쓴다.게스트로 장바구니 담기 체크아웃 페이지 이동 테스트 카드 입력 (4242 4242 4242 4242) 결제 버튼 클릭 결과: "Invalid payment method" 에러스크린샷 3장 첨부. 로그도 같이. 개발자가 "재현 안 되는데요?" 하면 환경 정보 준다.Browser: Chrome 120.0.6099 OS: Ubuntu 20.04 (CI server) Test account: test+guest@company.com API endpoint: staging.api.company.com애매할 때: 개발자 불러서 같이 본다. "이거 좀 봐줄 수 있어? 버그인지 스크립트 문제인지 모르겠어." 화면 공유하면서 재현한다. 같이 디버깅. 이게 제일 빠르다. 혼자 고민하지 말기. 오늘의 결과 18개 실패 분석 끝났다. 결과는 이렇다.스크립트 문제: 14개 (UI 변경으로 셀렉터 깨짐) 진짜 버그: 3개 (결제 API, 장바구니 버그 2건) Flaky test: 1개 (네트워크 타임아웃, 무시)14개는 내가 고친다. 클래스명 업데이트하면 된다. # Before driver.find_element(By.CLASS_NAME, "btn-primary")# After driver.find_element(By.CLASS_NAME, "primary-button")30분이면 끝난다. 정규표현식으로 일괄 치환. 버그 3건은 JIRA 티켓 만들었다. Critical 2개, Medium 1개. 개발팀 스탠드업에서 공유 예정. Flaky test는 일단 Ignore 처리. 나중에 네트워크 Retry 로직 추가할 거다. 점심 먹고 스크립트 수정 시작한다. 오후에는 신규 기능 테스트 작성해야 한다. 결제 모듈 v2가 나온다. 끝나지 않는다. QA는 계속된다.분석하는 30분이 하루를 결정한다. 빨간 불을 초록으로 바꾸는 게 내 일이다.

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법

Pytest fixture로 테스트 데이터 관리하기: 야근 줄이는 법 야근의 시작 금요일 오후 6시. 퇴근 30분 전이다. "J님, 테스트 스위트 돌리는 데 왜 이렇게 오래 걸려요?" 개발팀장 질문이다. 답은 알고 있다. 매번 디비 초기화하느라 30분씩 날린다. "최적화 좀 해보겠습니다." 그렇게 주말을 픽스처 공부로 보냈다.문제는 반복이었다 월요일 출근. 테스트 코드를 다시 봤다. def test_user_login(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = login("test@test.com") assert result.success db.close()def test_user_logout(): db = create_db_connection() db.clean_all_tables() db.insert_test_user() # 실제 테스트 result = logout() assert result.success db.close()똑같은 셋업이 50개 테스트마다 반복된다. 디비 초기화가 30초씩 걸린다. 50개 × 30초 = 25분. 순수 셋업 시간만. "이거 미친 짓이었네." 픽스처 공부한 보람이 있다. 바로 리팩토링 시작했다. fixture 기본부터 conftest.py 파일을 만들었다. import pytest@pytest.fixture def db_connection(): db = create_db_connection() yield db db.close()yield가 핵심이다. 앞은 셋업, 뒤는 티어다운. 자동으로 실행된다. 테스트 코드가 간단해졌다. def test_user_login(db_connection): db = db_connection result = login("test@test.com") assert result.successclose()를 신경 쓸 필요가 없다. 픽스처가 알아서 정리한다. 첫 번째 개선. 5분 절약.scope로 시간 줄이기 문제는 여전했다. 매 테스트마다 디비 연결을 새로 만든다. scope를 알게 됐다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()session scope. 전체 테스트 스위트에서 한 번만 실행된다. 하지만 문제가 생겼다. 테스트끼리 데이터가 꼬인다. "아, 연결은 유지하되 데이터는 초기화해야 하는구나." 다시 수정했다. @pytest.fixture(scope="session") def db_connection(): db = create_db_connection() yield db db.close()@pytest.fixture(scope="function") def clean_db(db_connection): db_connection.clean_all_tables() return db_connection연결은 세션당 한 번. 테이블 초기화는 테스트마다. 실행 시간이 25분에서 8분으로 줄었다. 17분 절약. "이제 좀 사람 사는 거 같네." 테스트 데이터 픽스처 다음 문제. 테스트 데이터 준비가 중복됐다. def test_admin_access(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = access_admin_page(admin) assert result.successdef test_admin_delete(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) result = delete_user(admin) assert result.successadmin 유저 생성 코드가 계속 반복된다. 픽스처로 뺐다. @pytest.fixture def admin_user(clean_db): admin = User(email="admin@test.com", role="admin") clean_db.insert(admin) return admindef test_admin_access(admin_user): result = access_admin_page(admin_user) assert result.success테스트 코드가 의도만 남았다. 셋업 코드가 사라졌다. 가독성이 올라갔다. 유지보수도 쉬워졌다. 파라미터로 여러 케이스 일반 유저, 관리자, 게스트. 세 가지 권한 테스트가 필요했다. 처음엔 테스트를 세 개 만들려고 했다. 비효율적이다. @pytest.fixture(params=[ {"email": "user@test.com", "role": "user"}, {"email": "admin@test.com", "role": "admin"}, {"email": "guest@test.com", "role": "guest"} ]) def test_user(request, clean_db): user = User(**request.param) clean_db.insert(user) return userdef test_user_access(test_user): result = access_page(test_user) assert result.success테스트 하나가 자동으로 세 번 실행된다. 각각 다른 유저로. pytest 출력도 깔끔하다. test_user_access[user] PASSED test_user_access[admin] PASSED test_user_access[guest] PASSED코드는 한 번 작성. 케이스는 무한 확장.autouse로 자동 실행 로그 관리가 필요했다. 모든 테스트마다. @pytest.fixture(autouse=True) def setup_logging(): logger = setup_test_logger() yield logger.save_results()autouse=True. 명시 안 해도 자동으로 실행된다. 모든 테스트 함수에 로깅이 적용됐다. 코드 수정 없이. "이건 진짜 마법 같네." 실전 구조 conftest.py를 계층화했다. tests/ conftest.py # 전역 픽스처 api/ conftest.py # API 테스트용 test_user.py ui/ conftest.py # UI 테스트용 test_login.py전역 conftest.py: @pytest.fixture(scope="session") def db_connection(): # 디비 연결 pass@pytest.fixture def clean_db(db_connection): # 테이블 초기화 passapi/conftest.py: @pytest.fixture def api_client(): # API 클라이언트 pass@pytest.fixture def auth_header(api_client): # 인증 헤더 pass필요한 픽스처만 불러온다. 테스트가 가벼워졌다. 픽스처 조합 픽스처끼리 조합할 수 있다. @pytest.fixture def user(clean_db): user = User(email="test@test.com") clean_db.insert(user) return user@pytest.fixture def logged_in_user(user, api_client): token = api_client.login(user) user.token = token return user@pytest.fixture def user_with_data(logged_in_user, clean_db): data = create_test_data() clean_db.insert_for_user(logged_in_user, data) return logged_in_user세 단계 픽스처다. 로그인까지, 데이터까지. 선택 가능하다. def test_simple(user): # 유저만 필요 passdef test_with_login(logged_in_user): # 로그인된 유저 passdef test_full_scenario(user_with_data): # 전부 준비된 상태 pass필요한 만큼만 셋업한다. 시간 절약이다. 실제 성과 리팩토링 전후 비교했다. 전:테스트 50개 실행: 32분 셋업 코드 중복: 200줄 디비 연결: 50번 티어다운 누락: 가끔후:테스트 50개 실행: 8분 픽스처 재사용: 10개 디비 연결: 1번 티어다운: 자동24분 절약. 하루에 테스트 3번 돌린다. 72분 단축. 주 5일이면 6시간. 거의 야근 하루가 사라졌다. 팀원 반응 후배 QA한테 픽스처 가르쳤다. "이거 진짜 편하네요." 개발팀장도 만족했다. "CI 파이프라인이 30분 빨라졌어요." 테크 리드가 물었다. "다른 팀도 적용 가능할까요?" "conftest.py 공유하면 됩니다." 지금은 전사 표준이 됐다. 모든 테스트가 픽스처 기반이다. 주의할 점 픽스처 남용하지 말 것. 간단한 테스트에 복잡한 픽스처는 과하다. 테스트 3줄인데 픽스처 20줄이면 문제다. scope 실수 조심. session scope에 function 데이터 넣으면 망한다. 픽스처 이름 명확하게. data보다 user_test_data가 낫다. 의존성 순환 주의. 픽스처가 서로 부르면 안 된다. 다음 단계 factory_boy 도입 검토 중이다. 픽스처 + 팩토리 패턴. @pytest.fixture def user_factory(clean_db): return UserFactorydef test_multiple_users(user_factory): users = user_factory.create_batch(10) # 10명 유저로 테스트더 유연해진다. 랜덤 데이터도 가능하다. Faker 라이브러리도 붙이면 좋겠다. 실제 같은 데이터로. 지금 시작하기 픽스처 리팩토링 순서:중복 셋업 코드 찾기 (Ctrl+F "setup") conftest.py 만들고 기본 픽스처 작성 yield로 티어다운 자동화 scope 최적화 (function → class → module → session) 파라미터로 케이스 확장 팀 공유하루면 충분하다. 효과는 즉시 나타난다. 마무리 금요일 6시. 테스트 스위트 실행했다. 8분 만에 완료. 초록불. "퇴근 가능." 야근이 사라졌다. 픽스처 덕분이다. 테스트 코드도 코드다. 리팩토링이 필요하다. 반복을 제거하고 재사용하라. 시간은 돌아온다. 야근 대신 정시 퇴근으로.이제 pytest.ini 설정도 정리해야겠다. 커버리지 리포트 경로가 엉망이다.

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

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

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