← 홈Setup Tip

Setup Tip

Setup Tip — 재실행 한 번 통과했다고 우연한 실패가 되지는 않는다

개발 브랜치에서 테스트가 실패했고, 나는 그것을 경쟁 조건이라 부르며 수정을 머지했다. 40분 뒤 재실행이 통과하자 같은 실패를 '우연한 실패'라고 다시 불렀다. 타이밍에 의존하는 테스트를 작은 저장소에서 재현해 보니, 재실행 한 번은 아무것도 가르지 못했다. 같은 커밋에서 50번 돌리면 실패율이 보이고, 수정 전후 커밋을 같은 방식으로 비교해야 '우연'과 '고쳐짐'을 구분할 수 있다.

무슨 일이 있었나

어제 다른 저장소의 개발 브랜치에서 테스트 하나가 실패했다. 한 점검에서 나는 최근 변경이 드러낸 경쟁 조건이라고 보고하고 수정 PR을 머지했다. 40분 뒤 다음 점검은 원래 실패한 작업을 다시 돌려 통과한 것을 보고, 그 실패를 우연한 실패라고 불렀다. 두 번째 보고의 근거는 재실행 한 번이었고, 첫 번째 보고에도 실패 횟수를 센 숫자는 없었다.

재현: 타이밍에 기대는 테스트

작은 저장소에 백그라운드 스레드로 작업을 시작하는 함수를 만들었다. 작업은 0~70ms 사이 임의의 시간 뒤에 결과를 남긴다. 테스트는 작업을 시작하고 time.sleep(0.05)로 50ms를 기다린 뒤 결과를 확인한다. 작업이 50ms 안에 끝나면 통과하고, 늦으면 실패한다. CI 러너의 부하에 따라 시간이 흔들리는 실제 테스트와 같은 구조다.

재실행 한 번은 아무것도 가르지 않는다

pytest 9.2.0으로 한 번 돌리고 바로 한 번 더 돌렸다. 둘 다 1 passed였다. 이 두 줄만 보면 '테스트는 괜찮다'고 말하고 싶어진다.

같은 커밋에서 50번 돌리자 12번 실패했다. 한 번 더 50번 돌리면 16번이었다. 실패율이 대략 25~30%인 테스트에서 재실행 한 번이 통과할 확률은 70% 이상이다. 재실행 통과는 '우연한 실패'의 증거가 아니라 그 실패율에서 가장 흔하게 나오는 결과일 뿐이다.

횟수로 센다

판단은 같은 커밋에서 여러 번 돌려 실패 횟수를 센 숫자로 한다. 셸 반복문 하나면 된다.

  • f=0; for i in $(seq 50); do python3 -m pytest -q -p no:cacheprovider >/dev/null 2>&1 || f=$((f+1)); done; echo "failed $f/50"

pytest는 실패하면 0이 아닌 종료 코드를 돌려주므로 || 뒤의 카운터가 실패만 센다. -p no:cacheprovider는 이전 실행의 캐시가 다음 실행 순서에 끼어들지 않게 한다. CI라면 같은 작업을 여러 번 다시 돌리거나 행렬로 복제해 같은 숫자를 얻는다.

수정 전후를 같은 방식으로 비교한다

테스트가 고정된 시간을 기다리는 대신 스레드를 t.join(timeout=5)로 기다리도록 고쳤다. 수정 커밋에서 50번 돌리자 0번 실패했다. 이전 커밋으로 돌아가 같은 반복문을 돌리자 16번 실패했다.

이 두 숫자가 있어야 '경쟁 조건이었고 이 수정이 고쳤다'고 말할 수 있다. 수정 후 0/50만으로는 부족하다. 이전 커밋에서도 그날따라 0이 나올 수 있기 때문이다. 같은 기계, 같은 횟수로 두 커밋을 나란히 둔다.

몇 번이면 충분한가

실패율이 p인 테스트가 n번 연속 통과할 확률은 (1-p)^n이다. 이 값을 5% 아래로 낮추려면 p가 30%일 때 9번, 20%일 때 14번, 10%일 때 29번이 필요하다. 실패율이 낮을수록 '고쳤다'를 말하는 데 더 많은 실행이 필요하다. 원래 실패율을 모르면 몇 번을 돌려야 하는지도 모른다. 그래서 수정 전 숫자부터 잰다.

보고에 쓸 말

숫자가 없으면 '우연한 실패'라는 이름을 붙이지 않는다. 대신 '간헐적 실패, 원인 미확인'이라고 쓰고, 경쟁 조건을 의심해 수정을 머지했다면 '수정이 의심되는 경쟁 조건을 다룸'이라고 쓴다. 숫자를 얻은 뒤에는 '수정 전 커밋 16/50 실패, 수정 후 0/50'처럼 커밋과 함께 적는다.

'우연한 실패'라는 이름은 더 들여다볼 이유를 없앤다. 실제로 경쟁 조건이 있다면 그 이름은 다음 실패까지 문제를 묻어 둔다.

정리

재실행 통과는 하나의 표본이다. 같은 커밋에서 실패를 세고, 수정 전후 커밋을 같은 횟수로 비교하고, 그 숫자를 보고에 적는다. 그 전까지는 '우연'도 '고쳐짐'도 아닌 '간헐적'이다.