Showing Posts From

Cicd

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

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

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

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가

Flaky 테스트와의 전쟁: 왜 같은 테스트가 오늘은 성공, 내일은 실패인가 새벽 3시, 슬랙 알림 새벽 3시. 슬랙 알림음에 눈이 떠졌다. "CI Build Failed - 17 tests failed" 저녁에 분명 다 통과했던 테스트들이었다. 아침 출근해서 다시 돌렸다. 전부 통과. 이게 벌써 이번 주에 세 번째다. 팀 슬랙에 개발자가 물었다. "J님, 테스트 불안정한 것 같은데 확인 가능하세요?" 확인 가능하다. 불안정한 거 맞다. 그게 문제다. Flaky 테스트. QA 자동화의 숙적. 같은 코드, 같은 환경인데 결과가 다르다. 오늘은 통과, 내일은 실패. 아침엔 성공, 저녁엔 실패. 이거 해결하려고 지난 한 달을 썼다.Flaky 테스트가 뭔데 Flaky 테스트는 비결정적 테스트다. 동일한 조건인데 결과가 달라진다. 예를 들면 이렇다. def test_user_login(): driver.get("https://example.com/login") 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() # 여기서 가끔 실패함 assert driver.find_element(By.ID, "dashboard").is_displayed()10번 중 7번은 통과한다. 3번은 실패한다. 실패 이유가 매번 다르다."Element not found" "Timeout" "Stale element reference"코드는 안 바뀌었다. 테스트 대상도 안 바뀌었다. 그런데 결과가 다르다. 우리 팀 자동화 테스트 1,247개. 이중 Flaky 테스트가 43개였다. 3.4%다. 적어 보인다? 절대 아니다. 43개가 랜덤하게 실패하면, 한 번 돌릴 때마다 5~10개씩 빨간불. 개발자들이 CI 결과를 신뢰 안 한다. "아 그거 또 Flaky 테스트 아니에요?" 자동화의 신뢰도가 무너진다. 원인 1: 타이밍 이슈 가장 흔한 원인. 타이밍. 웹 페이지는 로딩 시간이 들쭉날쭉하다. 네트워크 상태, 서버 부하, 브라우저 렌더링. 테스트는 기다려주지 않는다. # 문제 있는 코드 driver.find_element(By.ID, "submit-btn").click() assert driver.find_element(By.ID, "success-message").is_displayed()클릭하고 바로 체크한다. 성공 메시지가 나타나는 데 0.5초 걸린다면? 실패한다. 내가 처음 작성한 테스트 중 30%가 이랬다. 로컬에선 통과. CI에선 실패. 로컬은 내 맥북 프로 M1이다. CI 서버는 2코어 가상머신이다. 속도가 다르다. 당연히 타이밍이 안 맞는다. 해결책은 명시적 대기. from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ECdriver.find_element(By.ID, "submit-btn").click()# 최대 10초 대기 wait = WebDriverWait(driver, 10) element = wait.until( EC.visibility_of_element_located((By.ID, "success-message")) ) assert element.is_displayed()명시적으로 조건을 기다린다. "이 요소가 보일 때까지" "이 텍스트가 나타날 때까지" 모든 테스트에 적용했다. Flaky 비율이 43개에서 28개로 줄었다. 하지만 충분하지 않았다.원인 2: 상태 관리 문제 테스트들이 서로 영향을 준다. 테스트 A가 데이터를 만든다. 테스트 B가 그 데이터를 쓴다. 테스트 C가 그 데이터를 지운다. 순서대로 실행되면 문제없다. Pytest는 병렬 실행한다. C → A → B 순서로 돌면? B가 실패한다. 데이터가 없으니까. 실제 있었던 케이스다. # test_user_creation.py def test_create_user(): user = create_user(email="test@test.com") assert user.id == 12345# test_user_login.py def test_login_existing_user(): # test_create_user가 먼저 돌았다고 가정 response = login(email="test@test.com") assert response.status_code == 200테스트 간 의존성이 있다. 독립적이지 않다. 첫 실행엔 순서가 맞아서 통과. 두 번째 실행엔 순서가 바뀌어서 실패. 해결은 격리다. @pytest.fixture(scope="function") def test_user(): # 각 테스트마다 새 유저 생성 user = create_user(email=f"test_{uuid4()}@test.com") yield user # 테스트 끝나면 삭제 delete_user(user.id)def test_login_existing_user(test_user): response = login(email=test_user.email) assert response.status_code == 200각 테스트가 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 독립적으로 돌아간다. 이것도 적용했다. 28개에서 19개로 줄었다. 하지만 아직 19개가 남았다. 원인 3: 네트워크 불안정성 외부 API 호출하는 테스트들. 우리 서비스는 결제 API를 쓴다. PG사 테스트 서버에 요청 보낸다. def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"이 테스트는 외부 의존성이 있다. PG사 테스트 서버 상태에 달렸다. 서버 점검: 실패 네트워크 지연: 타임아웃 레이트 리밋: 429 에러 우리 코드는 멀쩡한데 실패한다. 처음엔 재시도 로직을 넣었다. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2)) def test_payment_processing(): response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"3번까지 재시도한다. 그래도 가끔 실패한다. 근본적 해결책은 모킹이다. @patch('payment_gateway.charge') def test_payment_processing(mock_charge): mock_charge.return_value = PaymentResponse(status="success") response = payment_gateway.charge( amount=10000, card_number="4111111111111111" ) assert response.status == "success"외부 API를 가짜로 대체한다. 항상 예측 가능한 응답을 준다. "그럼 진짜 API 연동은 안 테스트해요?" E2E 테스트 몇 개만 실제 API 쓴다. 나머지는 모킹한다. 통합 테스트와 E2E를 분리했다. 통합 테스트는 빠르고 안정적이다. E2E는 느리지만 실제 환경이다. 19개에서 11개로 줄었다.원인 4: 동시성 문제 가장 디버깅하기 어려운 케이스. 우리 앱은 웹소켓으로 실시간 알림을 보낸다. 테스트에서 알림이 오는지 체크한다. def test_notification_received(): send_notification(user_id=123, message="Test") # 알림이 올 때까지 대기 notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1100번 중 95번은 통과한다. 5번은 알림이 안 온다. 왜일까? 웹소켓 연결 타이밍 문제였다.테스트가 시작된다 웹소켓 연결을 시작한다 알림을 보낸다 웹소켓이 아직 연결 안 됐다 알림을 못 받는다순서가 꼬인다. 레이스 컨디션이다. 해결책은 연결 확인. def test_notification_received(): # 웹소켓 연결 대기 wait_for_websocket_connection(user_id=123) # 연결 확인 후 알림 전송 send_notification(user_id=123, message="Test") notifications = wait_for_notification(user_id=123, timeout=5) assert len(notifications) == 1연결이 완료된 걸 확인하고 진행한다. 비슷한 문제가 또 있었다. 데이터베이스 트랜잭션이다. def test_user_update(): update_user(user_id=123, name="New Name") # 바로 조회 user = get_user(user_id=123) assert user.name == "New Name"가끔 실패한다. 이전 이름이 나온다. 왜? 트랜잭션 커밋이 비동기다. 업데이트 요청하고 바로 읽으면, 아직 커밋 안 된 상태를 읽는다. def test_user_update(): update_user(user_id=123, name="New Name") # 커밋 대기 wait_for_transaction_commit() user = get_user(user_id=123) assert user.name == "New Name"명시적으로 커밋을 기다린다. 11개에서 6개로 줄었다. 원인 5: 테스트 데이터 충돌 여러 테스트가 같은 데이터를 쓴다. def test_user_search(): results = search_users(query="test@test.com") assert len(results) == 1def test_user_creation(): user = create_user(email="test@test.com") assert user.email == "test@test.com"첫 번째 테스트는 1개를 기대한다. 두 번째 테스트가 먼저 돌면? 같은 이메일로 유저가 생긴다. 첫 번째 테스트가 실패한다. 2개가 검색되니까. 병렬 실행하면 더 복잡하다. A와 B가 동시에 같은 이메일로 유저 생성. 둘 다 실패할 수 있다. 중복 키 에러. 해결책은 고유한 데이터. def test_user_search(): unique_email = f"test_{uuid4()}@test.com" create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1def test_user_creation(): unique_email = f"test_{uuid4()}@test.com" user = create_user(email=unique_email) assert user.email == unique_emailUUID로 유니크한 값을 만든다. 충돌이 없다. 픽스처로 패턴화했다. @pytest.fixture def unique_email(): return f"test_{uuid4()}@test.com"def test_user_search(unique_email): create_user(email=unique_email) results = search_users(query=unique_email) assert len(results) == 1모든 테스트에 적용했다. 6개에서 3개로 줄었다. 마지막 3개 3개가 남았다. 하나는 브라우저 캐시 문제였다. 이전 테스트의 쿠키가 남아있어서, 로그아웃 테스트가 실패했다. 해결: 각 테스트마다 브라우저 재시작. @pytest.fixture(scope="function") def driver(): driver = webdriver.Chrome() yield driver driver.quit()하나는 날짜/시간 의존 테스트. def test_daily_report(): report = generate_daily_report(date=today()) assert report.date == today()자정을 넘기면 실패한다. 테스트 시작할 때는 11시 59분. 체크할 때는 0시 1분. 해결: 고정된 날짜 사용. def test_daily_report(): test_date = datetime(2024, 1, 15) report = generate_daily_report(date=test_date) assert report.date == test_date마지막 하나는 메모리 누수. 테스트가 100개씩 돌면, 70번째쯤에서 브라우저가 느려진다. 80번째쯤 타임아웃. 해결: 주기적 재시작. test_count = 0@pytest.fixture(scope="function") def driver(): global test_count test_count += 1 if test_count % 50 == 0: # 50개마다 브라우저 재시작 restart_browser() driver = webdriver.Chrome() yield driver driver.quit()3개를 다 잡았다. Flaky 테스트 0개. 지금 우리 시스템 Flaky 테스트를 추적하는 대시보드를 만들었다. class FlakyTracker: def __init__(self): self.test_history = {} def record_result(self, test_name, passed): if test_name not in self.test_history: self.test_history[test_name] = [] self.test_history[test_name].append(passed) # 최근 100번 실행 중 성공률 recent = self.test_history[test_name][-100:] success_rate = sum(recent) / len(recent) if success_rate < 0.95: alert_flaky_test(test_name, success_rate)성공률이 95% 미만이면 알림. 자동으로 슬랙에 리포트. "test_user_login의 성공률이 92%입니다." 즉시 확인하고 고친다. CI 설정도 바꿨다. # .github/workflows/test.yml - name: Run Tests run: pytest --reruns 2 --reruns-delay 1실패한 테스트는 자동으로 한 번 더 돌린다. 진짜 버그면 두 번 다 실패한다. Flaky면 두 번째는 통과한다. 완벽한 해결책은 아니다. 하지만 실용적이다. 테스트 실행 시간도 최적화했다.빠른 테스트: 단위 테스트, 모킹 사용 중간 테스트: 통합 테스트, 일부 모킹 느린 테스트: E2E, 실제 환경빠른 테스트는 PR마다. 중간 테스트는 머지 전. 느린 테스트는 배포 전. 각각 역할이 다르다. 결과 Flaky 테스트 비율: 3.4% → 0.1% CI 신뢰도가 올라갔다. 개발자들이 테스트 결과를 믿는다. "CI 실패했는데 확인 부탁드려요." 이제 이 메시지가 오면, 진짜 버그다. 배포 전 불안감이 줄었다. 테스트가 통과하면 배포한다. 의심 안 한다. 자동화 테스트의 가치가 올라갔다. 하지만 유지보수 시간은 늘었다. 명시적 대기, 픽스처, 모킹. 코드가 복잡해졌다. 테스트 하나 작성하는 시간:전: 15분 후: 30분두 배가 됐다. 하지만 디버깅 시간은 줄었다.전: 주당 10시간 후: 주당 2시간트레이드오프다. 초반에 시간 더 쓰고, 나중에 시간을 아낀다. 교훈 Flaky 테스트는 기술 부채다. 쌓이면 감당 못 한다. 처음엔 몇 개 괜찮아 보인다. "이거 가끔 실패하는데 뭐 괜찮겠지." 아니다. 절대 괜찮지 않다. 하나가 두 개 되고, 두 개가 열 개 된다. 어느 순간 자동화를 신뢰 못 한다. 자동화의 의미가 없어진다. 발견 즉시 고쳐야 한다. '나중에'는 없다. 두 번째. 근본 원인을 찾아야 한다. 재시도 로직으로 때우지 마라. # 이렇게 하지 마라 @retry(stop=stop_after_attempt(5)) def test_something(): # flaky test증상만 감춘다. 문제는 남아있다. 왜 실패하는지 알아야 한다. 타이밍인지, 상태인지, 네트워크인지. 세 번째. 테스트는 독립적이어야 한다. 서로 영향 주면 안 된다. 각 테스트는 자기 데이터를 만든다. 끝나면 정리한다. 순서에 상관없이 돌아간다. 네 번째. 외부 의존성을 최소화한다. 모킹 가능한 건 모킹한다. E2E는 최소한으로. 다섯 번째. 모니터링한다. 어떤 테스트가 불안정한지 추적한다. 성공률을 기록한다. 데이터로 관리한다. 여전히 어려운 것들 완벽한 해결책은 없다. 새 기능 테스트를 작성할 때, Flaky 될지 안 될지 예측 못 한다. 돌려봐야 안다. 프론트엔드 E2E는 특히 어렵다. 브라우저, 렌더링, 애니메이션. 변수가 너무 많다. 모바일 앱 테스트는 더 심하다. 디바이스마다 다르다. OS 버전마다 다르다. 완전히 안정적인 자동화는, 아직 먼 이야기다. 하지만 계속 나아진다. 3.4%에서 0.1%로 왔다. 다음 목표는 0%다. 불가능해 보인다. 시도는 해볼 거다.새벽 3시 알림은 이제 안 온다. CI가 안정적이니까. 그래도 가끔 확인한다. 혹시 모르니까. Flaky와의 전쟁은 계속된다.