ポートフォリオ → negative-control

システムとツール · テストツールnegative-control

negative-control

合格したテストは、一度失敗するところを見るまで証拠になりません。このツールはコードをわざと壊し、それを検出すべきテストを実行して、コードを元に戻します。テストが失敗すれば、そのテストは何かを守っていたことになります。合格のままなら、何も守っていなかったということで、それがはっきりします。

手法の設計図。作業中のファイルをコピーから復元してから、判定結果を報告します。
変更前先に合格を確認 壊す指定した変更 テスト指定テスト 復元コピーから復元 判定結果を報告 検出 見逃し 実行されず 実行されなかったテストは、検出の証拠になりません。

横にスワイプすると図の全体が見られます。

手法の設計図。作業中のファイルをコピーから復元してから、判定結果を報告します。
課題

合格したテストが、守るはずの動作を一度も確認していないことがあります。実行できなかったテストも、有効な結果と誤認されかねません。

私の貢献

手法、判定の区分、安全のためのルールを定義しました。「見逃し」と「実行されず」を分け、作業中のファイルはコピーから復元する設計です。ツールはAIコーディングエージェントが実装しました。

成果・検証への入口

弱いテストの例、3種類の判定、安全ルールの根拠となった実際の失敗から設計をたどれます。

3種類の判定(2種類ではない):検出 · 見逃し · 実行されず
6件、以前のバージョンで実際に起きた失敗。どれも今はルールになっている
12通りの方法でツール自身を壊し、すべて自分のテストで検出 · 2026-09-29
30 → 0この手法がAICCで見つけた偽りの合格。その後すべて解消 · 2026年8月
合格が証拠にならない理由

テストは、間違った理由で合格することがある

tests/test_pricing.py
# passes today. Also passes with the fee set to 0,
# because 0 >= 0 is true. It never checked the fee.
def test_small_order_has_a_fee():
    assert shipping_fee(50) >= 0

# fixed: now breaking the fee turns it red
def test_small_order_has_a_fee():
    assert shipping_fee(50) == 8.0

テストは、違う値を確かめていたり、常に成り立つことを確かめていたり、名前に掲げたコードにそもそも届いていなかったりします。どれも見た目は、きちんと働くテストとまったく同じ「合格」です。

見分ける方法はひとつだけです。テストが守っているはずのコードを壊して、結果を見ること。人はこれを手作業でやります。1行書き換え、テストを実行し、元に戻す。失敗が起きるのは、この最後の一歩です。

仕組み

壊し方を書くのはあなた。残りはツールが慎重に引き受けます。

レシピを書く→1行壊す→指定したテストを実行→コピーから復元→判定
{ "label": "small orders ship free by mistake", "file": "pricing.py", "replace": " return SHIPPING_FEE", "with": " return 0.0", "test": "tests/test_pricing.py::test_small_order_has_a_fee" }
  • 壊すのは判断で、文言ではない。どのメッセージを選ぶかを変え、メッセージの文面は変えない
  • コメントアウトではなく削除する。コメントアウトしたコードは、ソースを検索するテストにまだ一致してしまう
  • 指定するテストは1つ。そうすれば「合格のまま」という判定が、弱いテストをまっすぐ指し示す
  • レシピは古くなる。--checkは、すべてのレシピがまだ対象のコードと一致しているかをミリ秒単位で確認する。テストスイートの中でも実行されるので、古くなったレシピは自動的に失敗として現れる
3つの判定

「実行されなかった」と「何も見つからなかった」は違う

red ✓

検出

手を加えていないコードではテストが合格し、壊したコードでは失敗しました。やることはありません。これが目指す状態です。

green ✗

見逃し

壊しても、すべてのテストが合格のままでした。テストか、テストが使うケースを直します。失敗させるためだけにレシピを変えることは、決してしません。

broken ?

実行されず

置き換える文字列が見つからない、対象がプロジェクトの外を指している、壊す前からテストが失敗していた、または時間切れになった。これは「結果なし」であり、合格として読むことは決してありません。

いちばん大事なのは3つ目の判定です。長い一覧の中では、何も測れなかった実行は、何も見つからなかった実行と同じに見えます。そして人が次にするのは、そのテストを消すことです。

失敗から生まれたルール

どの安全ルールも、実際に起きた失敗がもとになっている

コピーから復元する

初期のバージョンはgit checkoutで復元していました。これは最後のコミットの状態に戻す操作なので、同じファイルにあったコミット前のリファクタリングを消してしまいました。

終了コードを読む

出力の中から「failed」という文字を探していたときは、テスト収集時のエラーを合格と読み違え、何も確かめていない実行に「見逃し」と判定しました。

バイトコードキャッシュを消す

1秒以内に同じサイズの破壊を2つ続けると、2つ目も1つ目の破壊として実行され、2つ目が誤って「見逃し」と報告されました。

実行は一度に1つ

同じプロジェクトで2つの実行が重なると、それぞれが相手の壊したファイルを「元のファイル」として保存し、壊れたままのコードが残りました。今はプロジェクト自体にロックをかけ、2つ目の実行を拒否します。

プロジェクトの外に出ない

../xのようなパスや、プロジェクト内のフォルダーのショートカットを経由すると、プロジェクト外のファイルまで壊せてしまいました。今は、対象が実際にどこを指しているかで判断します。

まずテストが合格すること

最初から失敗しているテストは、どう壊しても失敗するので、「検出」と数えられていました。今は各テストを、まず手を加えていないコードで一度実行します。

現状と範囲

できていること、証明できたこと、届かないこと

できていること

  • Pythonファイル1つ。pytest以外の依存なし
  • レシピの形式、--checkによる古いレシピの検出、一度に1つだけ実行するためのロック
  • 見つけるべき弱いテストを含んだ実例

検証済み · 2026-09-29

  • ツール自身の27件のテストが合格
  • ツール自身を12通りに壊し、どれもテストが失敗。ファイルはきれいに元どおり
  • 実例の出力がドキュメントの記載と一致
  • 公開前にレビュー。修正内容と残っている制約はREADMEに記載

範囲

  • 対象は、誰かが思いついた壊し方だけ。テストが失敗しうることは示せるが、一度もテストされていない部分は示せない
  • あなたのテストを実際に実行するので、サンドボックスではない。信頼できるコードに使うこと
  • スキップされたテストは合格として読まれてしまい、強制終了されるとファイルを復元できない。対処法はREADMEに記載

GitHubのリポジトリと文書は英語です。

新しいタブで開きます