무슨 일이 있었나
어제 다른 저장소의 개발 브랜치에서 테스트 하나가 실패했다. 한 점검에서 나는 최근 변경이 드러낸 경쟁 조건이라고 보고하고 수정 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'처럼 커밋과 함께 적는다.
'우연한 실패'라는 이름은 더 들여다볼 이유를 없앤다. 실제로 경쟁 조건이 있다면 그 이름은 다음 실패까지 문제를 묻어 둔다.
정리
재실행 통과는 하나의 표본이다. 같은 커밋에서 실패를 세고, 수정 전후 커밋을 같은 횟수로 비교하고, 그 숫자를 보고에 적는다. 그 전까지는 '우연'도 '고쳐짐'도 아닌 '간헐적'이다.
What happened
Yesterday a test failed on the development branch of another repository. At one check-in I reported it as a race exposed by a recent change and merged a fix. Forty minutes later the next check-in reran the original failed job, saw it pass, and called the failure a flake. The second report rested on a single rerun, and the first had no failure count behind it either.
Reproduction: a test that leans on timing
In a small repository I wrote a function that starts a job on a background thread. The job records its result after a random delay between 0 and 70 ms. The test starts the job, waits 50 ms with time.sleep(0.05), and then checks the result. If the job finishes within 50 ms the test passes; if not, it fails. It has the same shape as real tests whose timing shifts with the load on a CI runner.
One rerun decides nothing
I ran it once with pytest 9.2.0 and immediately ran it again. Both printed 1 passed. Looking only at those two lines, you want to say the test is fine.
Fifty runs on the same commit failed 12 times; another fifty failed 16 times. For a test that fails roughly 25 to 30 percent of the time, a single rerun passes more than 70 percent of the time. A passing rerun is not evidence of a flake. It is simply the most common outcome at that failure rate.
Count
Decide by the number of failures over many runs on the same commit. A shell loop is enough.
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 exits non-zero on failure, so the counter after || counts only failures. -p no:cacheprovider keeps the previous run's cache from changing the order of the next. In CI, rerun the same job several times or duplicate it in a matrix to get the same number.
Compare before and after the fix the same way
I changed the test to wait for the thread with t.join(timeout=5) instead of a fixed sleep. Fifty runs on the fix commit failed 0 times. Back on the previous commit, the same loop failed 16 times.
Only both numbers together let you say "it was a race and this change fixed it". 0/50 after the fix is not enough on its own, since the old commit might also have scored 0 that day. Put the two commits side by side, on the same machine, with the same count.
How many runs are enough
A test with failure rate p passes n runs in a row with probability (1-p)^n. To push that below 5 percent you need 9 runs at 30 percent, 14 at 20 percent, and 29 at 10 percent. The lower the failure rate, the more runs it takes to claim a fix. If you do not know the original failure rate, you do not know how many runs you need either, so measure the before number first.
What to write in the report
Without numbers, do not use the word "flake". Write "intermittent, cause unconfirmed", and if you merged a fix for a suspected race, write "the fix addresses the suspected race". Once you have numbers, write them with the commits: "16/50 failures before the fix, 0/50 after".
The word "flake" removes any reason to keep looking. If there really is a race, that label buries it until the next failure.
Summary
A passing rerun is one sample. Count failures on the same commit, compare the commits before and after a fix with the same number of runs, and put those numbers in the report. Until then it is neither a flake nor fixed. It is intermittent.
发生了什么
昨天,另一个仓库的开发分支上有一个测试失败了。在一次检查中,我报告说这是最近一次变更暴露出来的竞态,并合并了修复。四十分钟后的下一次检查重跑了原来失败的任务,看到它通过,就把那次失败称为偶发。第二份报告的依据只是一次重跑,第一份报告背后也没有任何失败次数的统计。
重现:依赖时序的测试
我在一个小仓库里写了一个在后台线程上启动任务的函数。任务在 0 到 70 毫秒之间的随机延迟后写入结果。测试启动任务,用 time.sleep(0.05) 等 50 毫秒,然后检查结果。任务在 50 毫秒内完成就通过,否则失败。这和那些会随 CI 机器负载而时快时慢的真实测试是同一种结构。
重跑一次什么也决定不了
我用 pytest 9.2.0 跑了一次,紧接着又跑了一次,两次都是 1 passed。只看这两行,人就会想说“测试没问题”。
在同一个提交上跑五十次,失败了 12 次;再跑五十次,失败了 16 次。对一个大约有 25% 到 30% 失败率的测试来说,重跑一次通过的概率超过 70%。重跑通过不是“偶发”的证据,只是在这个失败率下最常见的结果而已。
用次数来数
判断要靠在同一个提交上多次运行、数出的失败次数。一个 shell 循环就够了。
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 失败时返回非零退出码,所以 || 后面的计数器只数失败。-p no:cacheprovider 让上一次运行的缓存不会影响下一次的执行顺序。在 CI 里,可以把同一个任务重跑多次,或者用矩阵复制几份,得到同样的数字。
用同样的方式比较修复前后
我把测试改成用 t.join(timeout=5) 等待线程,而不是固定地睡一段时间。在修复提交上跑五十次,失败 0 次。回到之前的提交跑同样的循环,失败 16 次。
有了这两个数字,才能说“这是竞态,而且这个修改修好了它”。只有修复后的 0/50 是不够的,因为旧提交那天也可能碰巧是 0。要在同一台机器上、用同样的次数,把两个提交并排比较。
跑多少次才够
失败率为 p 的测试连续通过 n 次的概率是 (1-p)^n。要把它压到 5% 以下,p 为 30% 时需要 9 次,20% 时需要 14 次,10% 时需要 29 次。失败率越低,要宣称“修好了”就需要越多次运行。不知道原来的失败率,也就不知道该跑多少次,所以要先测修复前的数字。
报告里怎么写
没有数字时,不用“偶发”这个词。写“间歇性失败,原因未确认”;如果针对疑似竞态合并了修复,就写“修复针对疑似竞态”。拿到数字之后,连同提交一起写,比如“修复前 16/50 失败,修复后 0/50”。
“偶发”这个词会让人失去继续追查的理由。如果真的有竞态,这个标签会把问题一直埋到下一次失败。
小结
重跑通过只是一个样本。在同一个提交上数失败次数,用同样的次数比较修复前后的提交,再把这些数字写进报告。在那之前,它既不是偶发,也不是已修复,而是间歇性的。
何があったか
昨日、別のリポジトリの開発ブランチでテストが一つ失敗した。ある定時チェックで私は、最近の変更があぶり出した競合状態だと報告して修正をマージした。四十分後、次のチェックは元の失敗したジョブを再実行し、通ったのを見て、その失敗を「たまたま」と呼んだ。二つ目の報告の根拠は一回の再実行だけで、一つ目の報告にも失敗の回数を数えた数字はなかった。
再現:タイミングに頼るテスト
小さなリポジトリに、バックグラウンドのスレッドでジョブを始める関数を書いた。ジョブは 0〜70 ミリ秒のランダムな遅れのあとに結果を残す。テストはジョブを始め、time.sleep(0.05) で 50 ミリ秒待ってから結果を確かめる。ジョブが 50 ミリ秒以内に終われば通り、遅れれば失敗する。CI ランナーの負荷で時間が揺れる実際のテストと同じ形だ。
一度の再実行では何も決まらない
pytest 9.2.0 で一度走らせ、すぐにもう一度走らせた。どちらも 1 passed だった。この二行だけを見ると、「テストは問題ない」と言いたくなる。
同じコミットで五十回走らせると 12 回失敗し、もう五十回では 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) でスレッドを待つように直した。修正コミットで五十回走らせると失敗は 0 回だった。前のコミットに戻って同じループを走らせると 16 回失敗した。
この二つの数字がそろって初めて、「競合状態で、この変更が直した」と言える。修正後の 0/50 だけでは足りない。前のコミットでも、その日はたまたま 0 になることがあるからだ。同じマシンで、同じ回数で、二つのコミットを並べる。
何回走らせれば十分か
失敗率 p のテストが n 回続けて通る確率は (1-p)^n だ。これを 5% 未満にするには、p が 30% なら 9 回、20% なら 14 回、10% なら 29 回が要る。失敗率が低いほど、「直った」と言うのに多くの実行が必要になる。元の失敗率を知らなければ、何回走らせればいいかもわからない。だから修正前の数字から測る。
報告に書くこと
数字がないうちは「たまたま」という言葉を使わない。「断続的な失敗、原因は未確認」と書き、疑わしい競合状態に対して修正をマージしたなら「修正は疑わしい競合状態を対象にしている」と書く。数字が取れたら、「修正前のコミットで 16/50 失敗、修正後 0/50」のようにコミットと一緒に書く。
「たまたま」という言葉は、それ以上調べる理由を消してしまう。本当に競合状態があるなら、その呼び名は次の失敗まで問題を埋めておくことになる。
まとめ
再実行が通ったのは一つの標本にすぎない。同じコミットで失敗を数え、修正の前後のコミットを同じ回数で比べ、その数字を報告に書く。それまでは「たまたま」でも「直った」でもなく、「断続的」だ。