Dev Study
← 開発の実務
レッスン 5 / 12·約 9 分·第16期 開発の実務

直す前に「壊れている」と判定できる手順を作る

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回は、時々落ちるテストには書かれていない入力があることを見ました。今回比べる相手は、直す前に用意する判定手順そのものです。

まず結論

「壊れていない」は何と比べたかでしか言えません。「直った」も同じで、比べる相手は、直す前に「壊れている」と答えた判定手順です。それが無いまま直すと、直ったかどうかを何とも比べられません。

その判定手順は、答えを3つ持つ必要があります。追っている失敗が出た・出なかった・別の理由で判定できなかった。3つ目を「壊れている」に混ぜると、道具はもっともらしい別の答えを返してきます。

? 読む前に予測

`git bisect run` に渡した判定スクリプトの中でコマンド名を打ち間違え、シェルが終了コード127(command not found)で終わりました。git はそのコミットをどう扱うでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

「直った」は、何と比べて言うのか

このコースの芯は「『壊れていない』は、何と比べたかでしか言えない」です。直すときも同じで、「直った」と言うには比べる相手が要ります。直す前のコードで「壊れている」と答え、直した後で「壊れていない」と答える判定手順です。直す前に一度も「壊れている」と答えたことのない手順が、直した後に「壊れていない」と答えても、何も言っていません。

この回では、失敗を意図して起こすことを不具合の再現と呼びます。他の回で使っている「実測の再現性」(同じ測定で同じ値が出ること)とは別の意味です。

debuggingbook の入門章「Introduction to Debugging」は、デバッグを仮説・予測・実験の手順として書いています。ただし、この章に reproduce(再現)の語は出てきません。

再現を手順として書いているのは、同じ本の入力を縮める章(DeltaDebugger)です。縮め始める前に check_reproducibility で同じ呼び出しをもう一度実行し、結果が UNRESOLVED なら FailureNotReproducedError、PASS なら NotFailingError を投げます。FAIL が出なければ先へ進みません。

その判定は3値です。

Return * PASS if no exception occurred * FAIL if the collected exception occurred * UNRESOLVED if some other exception occurred.

実装は、例外の型とメッセージが両方元と一致したときだけ FAIL にします。落ちたかどうかではなく、追っている失敗と同じ落ち方をしたかを比べています。

判定手順が返す3つの答え
版や入力を1つ渡す判定手順が実行する
元の失敗と比べる
FAIL同じ失敗が出た = 再現した
PASS失敗しなかった
UNRESOLVED別の理由で落ちた = 判定できない
  • ・3つ目を FAIL に混ぜると、別の失敗を「再現した」と数える
  • ・3つ目をどう扱うかは道具ごとに違う(後述)
具体実物

2値の判定で縮めると、追っていた失敗が消える

設定文字列を読んで単価を返す関数に、不具合が1つあります。rate=0 でゼロ除算になります。一方で、= の無い部分があると ValueError で落ちます。こちらは今追っている失敗ではありません。

判定手順を2つ用意しました。debuggingbook の規則に従った3値版と、何であれ落ちたら FAIL にする2値版です。

def judge3(inp):
    try:
        price_per_unit(inp)
        return PASS
    except Exception as e:
        if type(e) is type(ORIGINAL) and str(e) == str(ORIGINAL):
            return FAIL
        return UNRESOLVED

どちらを使っても、入力から1文字ずつ消して、判定が FAIL のままなら採用する、という同じ素朴な手順で縮めます(ddmin ではありません)。Python 3.14.6 で2回実行し、2回とも同じ出力でした。

python3 judge3.py の1回目(見出し行と exit=0 の行を省略)コマンド出力
judge3('a=1;rate=0;b=2') = FAIL judge2 = FAIL
judge3('a=1;b=2') = PASS judge2 = PASS
judge3('a=1;rate') = UNRESOLVED judge2 = FAIL
judge3('rate=0') = FAIL judge2 = FAIL
judge3('x') = UNRESOLVED judge2 = FAIL
shrink with judge2: 'a=1;rate=0;b=2' -> '2' (ValueError: not enough values to unpack (expected 2, got 1))
shrink with judge3: 'a=1;rate=0;b=2' -> '=1;rate=0;=2' (ZeroDivisionError: division by zero)

3行目の 'a=1;rate' は落ちますが、ValueError です。2値版はこれを FAIL、つまり「再現した」と数えます。その結果、2値版で縮めると '2' に行き着き、追っていたゼロ除算が消えています。 3値版で縮めた結果にはゼロ除算が残ります。

3値版の結果 '=1;rate=0;=2' も最小ではありません(1文字ずつ消す手順の限界)。見たいのは縮め方ではなく、縮める手順が「再現した」を判定に任せきっていることです。

✓ ここまでの確認

2値の判定(何であれ例外が出たら FAIL)で入力を縮めると `'2'` になり、ValueError で落ちました。何が起きたのでしょうか。

抽象原理

3つ目の答えを、道具はどう扱うか

「判定できない」という答えを受け取った道具が次に何をするかは、道具ごとに違います。

debuggingbook はこう書いています。

Such an outcome that is different from the original failure causes our DeltaDebugger not treating this as a failure, but rather as a UNRESOLVED outcome, indicating that the test cannot determine whether it passed or failed. The ddmin algorithm treats such unresolved outcomes as if they were passing; hence, the algorithm treats its minimization attempt as unsuccessful.

ddmin は UNRESOLVED を通ったものと同じに扱い、その縮め方を採用しません。

git bisect run は判定スクリプトの終了コードを読みます。

Note that <cmd> run with <arg> should exit with code 0 if the current source code is good/old, and exit with a code between 1 and 127 (inclusive), except 125, if the current source code is bad/new.

The special exit code 125 should be used when the current source code cannot be tested. If the script exits with this code, the current revision will be skipped (see git bisect skip above).

0 が good、1〜127 のうち125以外が bad、125 が skip(飛ばす)、0〜127 の外は Any other exit code will abort the bisect process. で中断です。予測問題で見たとおり、126 と 127 も bad です。

同じ3値でも、ddmin は「通った」扱い、git は「飛ばす」扱いです。等号で結んではいけません。 共通しているのは1点、どちらも判定できない答えを**「再現した」には数えない**ことです。判定手順を書く側の仕事は、判定できない場合を見分けて、その道具が求める形で返すことです。

具体実物

読み込めない版を bad と答えると、別の境目が返る

10個のコミットを作りました。c5 で構文エラーを入れて c6 で直し、c8 で本物の不具合(合計が1ずれる)を入れます。判定スクリプトは2つです。素朴な版は、何であれ失敗したら終了コード1で終わります。3値版は、読み込めなければ125、合計がずれたら1、合っていれば0で終わります。

node -e 'import("./sum.mjs").then(m=>{ process.exit(m.sum([1,2,3])===6 ? 0 : 1) }, e=>{ console.error("UNRESOLVED:", e.name); process.exit(125) })'

各コミットで2つの判定を走らせた終了コードです(git 2.50.1、Node v24.21.0)。

各コミットでの判定の終了コード(生出力。2回とも同一)コマンド出力
c1 naive=0 judge3=0
c2 naive=0 judge3=0
c3 naive=0 judge3=0
c4 naive=0 judge3=0
c5 naive=1 judge3=125
c6 naive=0 judge3=0
c7 naive=0 judge3=0
c8 naive=1 judge3=1
c9 naive=1 judge3=1
c10 naive=1 judge3=1

どちらも c1 を good、c10 を bad として git bisect run をかけました。git の記録(git bisect log)はこうです。

1回目の生出力から、見出し行と bisect log だけを抜粋(途中の実行ログを省略)コマンド出力
### git bisect run judge-naive.sh (good=c1, bad=c10)
--- bisect log
git bisect start 'main' '45efe7fe4f8e5c99015a1e3909b2365002c4f758'
git bisect bad 74107334d39a6fd76d3602d860a746eb25522792
git bisect good 17761a98bebce4f1f07ec728ef8d6b0af53af13e
git bisect good 73905f9bd36842f8d3189210e3364b1168b43e74
### git bisect run judge3.sh (good=c1, bad=c10)
--- bisect log
git bisect start 'main' '45efe7fe4f8e5c99015a1e3909b2365002c4f758'
git bisect skip 74107334d39a6fd76d3602d860a746eb25522792
git bisect good cc3fb00657e20ec26c402e45dad311e9d77d521c
git bisect bad 04be1ea592b4b960fd55c3df743e9a098da6bcb6
git bisect good 479e58ecb3ffbdb2d7fe92407878a74cf7a11972

7410733 が c5、04be1ea が c8 です。素朴な判定では c5 に bad が付き、bisect は c5 を first bad commit として返しました。本物の不具合 c8 には届いていません。3値版では c5 に skip が付き、c8 を返しました。2回実行して、2回とも同じ結論です。

素朴な判定の答えは、判定にとっては正しい境目です。c4 で good、c5 で bad が返ったのですから。『半分にすると、なぜ20回で済むのか』で見たとおり、bisect は境目が1つという前提を検査せず、もっともらしい答えを1つ返します。誤っているのは探索ではなく、比べる相手として渡した判定のほうです。

✓ ここまでの確認

素朴な判定で `git bisect run` をかけると、構文エラーを入れた c5 が first bad commit として返りました。この出力だけを見て、誤りだと分かりますか。

⇄ 2つを見比べる
具体例 A

別の失敗を「再現した」と数える判定

何であれ落ちたら FAIL にする判定です。入力を縮めると ValueError の '2' に行き着き、bisect は構文エラーの c5 を返しました。FAIL は出ていますが、追っている失敗とは比べていません。

具体例 B

競合するコードが正しい値を返した1回

『同じ値を2人が書く』の実測です。2スレッドで排他制御なしに200万回加算し、3回のうち3回目がちょうど 2000000 になりました。この1回だけを判定に使えば PASS ですが、コードは壊れたままです。

『同じ値を2人が書く』の実測より(生出力・3回すべて)コマンド出力
=== plain RMW vs atomic_fetch_add (3 runs) ===
expected=2000000 plain=1081150 atomic=2000000
expected=2000000 plain=1643779 atomic=2000000
expected=2000000 plain=2000000 atomic=2000000

片方は落ちすぎる判定、片方は通りすぎる判定です。どちらも判定手順は答えを返しています。何が足りないのでしょうか。

それぞれの答えを「追っている不具合が、この版にあるか」という問いに言い換えられるかを考えてみてください。

この章のまとめ

  • 「直った」と言うには比べる相手が要る。直す前に「壊れている」と答えた判定手順がそれにあたる。
  • debuggingbook の判定は3値。元と同じ型・同じメッセージの例外だけ FAIL、別の例外は UNRESOLVED。縮める前に再実行して FAIL を確かめる。
  • 3つ目の扱いは道具で違う。ddmin は通った扱い、git bisect run は 125 で skip。125 を除く 1〜127(126・127 を含む)は bad、0〜127 の外は中断。等号で結ばない。
  • 判定できない版を bad と答えると、bisect は構文エラーの c5 を返し、本物の c8 に届かなかった。誤っていたのは判定。
  • 1回の PASS は直った証拠にならない。競合するコードも3回中1回は正しい値を返した。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

debuggingbook の DeltaDebugger の実装で、試した入力が FAIL(再現した)と判定されるのはどんなときですか。

用語集(4語)
不具合の再現
追っている失敗を意図して起こすこと。本コースの他の回で使う「実測の再現性」(同じ測定で同じ値が出ること)とは別の意味。
判定手順
ある版や入力について「追っている失敗が出たか」を答える手順。直す前に用意し、直した後の比較にも使う。
PASS / FAIL / UNRESOLVED
debuggingbook の DeltaDebugger が使う3値。例外なし・元と同じ例外・別の例外に対応する。ddmin は UNRESOLVED を PASS と同様に扱う。
git bisect skip
判定できないコミットを飛ばす操作。`git bisect run` では終了コード125がこれにあたる。境目の隣を飛ばすと、どちらが最初の bad か特定できなくなることがある。