0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

緑のテストが何も守っていない4つの形 —— カバレッジは通ったことしか教えてくれない

0
Last updated at Posted at 2026-09-08

2日のあいだに、何も検査していないのに緑だったテストを3件見つけた。
どれもカバレッジには計上される。行は通っている。だがバグを戻しても赤くならない。

さらに数日後、同じ形をもう1件踏んだ。合計4件、全部ちがう理由で空振りしていた。

この記事は、その4つの形と、それぞれをどう捕まえるか

大前提:カバレッジは「通った」しか言っていない

def test_発注できる():
    place_order(Order("AAA", "BUY", 10, 100.0))    # 例外が出なければ通る

このテストは place_order を100%カバーする。そして何も検査していない。
戻り値も、副作用も、状態も見ていない。バグを入れても、例外にならない限り緑のままだ。

カバレッジが測っているのは「実行された行」であって「守られている性質」ではない。

以下、実際に踏んだ4つ。カバレッジは全部100%だった。


形① 検査したい条件が、別の理由で成立していた

上限のチェックを書いた。「金額の上限を超えたら弾く」「ただし減らす操作は弾かない
(弾くと、上限を超えた状態から抜け出せなくなる)。

def test_上限を超えていても減らす操作は通る():
    gate = Gate(limits=Limits(max_notional=1000))
    assert gate.check(Order(side="DECREASE", notional=999_999)) is True

緑。だが通っていた理由が違った。
Limits には上限が2種類あって、テストは片方しか渡していない。
もう一方が None(=無制限)だったので、「減らす操作だから通った」のではなく
「そもそもどの上限も効いていなかった」から通っていた。

捕まえ方:バグを戻して、赤くなるのを見る

これが唯一確実な方法で、しかも一番安い。

  # 一時的に、向きの判定を消す
- if order.side == "INCREASE" and order.notional > lim:
+ if order.notional > lim:

これで赤くならなかったら、そのテストはその性質を守っていない。

安全に関わる修正は、毎回これをやる。 1分で終わる。
私はこれを一度もやっていなかったから、3件同時に見つかった。

そして片方だけでなく両方向を書く

def test_上限を超えたら増やす操作は弾く():      # ← これが無かった
    gate = Gate(limits=Limits(max_notional=1000, max_position=1000))
    assert gate.check(Order(side="INCREASE", notional=999_999)) is False

def test_上限を超えていても減らす操作は通る():
    gate = Gate(limits=Limits(max_notional=1000, max_position=1000))  # 両方渡す
    assert gate.check(Order(side="DECREASE", notional=999_999)) is True

形② 期待値に設定値を直書きしていた

def test_上限が設定されている():
    cfg = load_config()
    assert cfg.max_notional == 950_000     # ← 設定ファイルと同じ値を書いた

これは設定ファイルのコピーであって、テストではない。
値を変えた瞬間に落ちるが、落ちたら人はテスト側の数字を書き換える。
そうして、何も守らない空テストになる。

捕まえ方:値ではなく性質を書く

守りたいのは「950,000であること」ではなく「上限が設定されていて、
口座の規模に対して妥当であること
」のはずだ。

def test_絶対額の上限が設定されている():
    """未設定(None)だと無制限になる。**そこが事故る。**"""
    cfg = load_config()
    assert cfg.max_notional is not None
    assert cfg.max_position is not None

def test_1回の上限は建玉の上限を超えない():
    cfg = load_config()
    assert cfg.max_notional <= cfg.max_position

不変条件は、本番の設定ファイルを読んで検査する。
テスト用の値だけで確かめると、本番の設定が変わった瞬間に成立しなくなる。


形③ 既定値を引数に束縛していて、差し替えが効かない

これが一番気づきにくかった。

SCHEDULE = Path("config/schedule.toml")

def load_schedule(path: Path = SCHEDULE) -> dict:     # ← 既定値が定義時に固定される
    return tomllib.loads(path.read_text())

テストはこう書いていた。

def test_スケジュールを読む(tmp_path, monkeypatch):
    fake = tmp_path / "schedule.toml"
    fake.write_text('[[job]]\nname = "テスト"\n')
    monkeypatch.setattr(mod, "SCHEDULE", fake)    # 差し替えたつもり
    jobs = load_schedule()
    assert len(jobs) == 1

monkeypatch は効かない。 path=SCHEDULE は関数が定義された瞬間に評価されて
束縛済みなので、あとからモジュール変数を差し替えても関数の既定値は変わらない。

つまりこのテストは本番の設定ファイルを読んでいた。
そこにたまたまジョブが1件あったので、緑だった。

捕まえ方:既定値は呼び出し時に解決する

def load_schedule(path: Path | None = None) -> dict:
    path = path or SCHEDULE          # ← 呼ばれるたびに解決する
    return tomllib.loads(path.read_text())

そしてテストが本番ファイルを触っていないことを、テストで確かめる。

def test_テストが本番の設定を読んでいない(tmp_path, monkeypatch):
    real = SCHEDULE.read_bytes() if SCHEDULE.exists() else None
    fake = tmp_path / "s.toml"
    fake.write_text('[[job]]\nname = "A"\n')
    monkeypatch.setattr(mod, "SCHEDULE", fake)
    assert len(mod.load_schedule()) == 1
    after = SCHEDULE.read_bytes() if SCHEDULE.exists() else None
    assert real == after, "テストが本番ファイルを書き換えた"

差し替えが効いているか自体を検査する。 効いていないことは、緑では分からない。


形④ テストファイルが1件も実行されずに成功していた

CIやフックから python tests/test_foo.py で起動していた。
pytest で書いたファイルには、末尾にこれが要る。

if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-q"]))

書き忘れると、そのファイルは何も実行せず、終了コード0で終わる。
テストは1件も走らない。それでも緑。

私はこれを9日空けて2回踏んだ。

捕まえ方:「テストがある」ではなく「テストが走った」を検査する

pytest は収集0件のとき終了コード 5ExitCode.NO_TESTS_COLLECTED)を返す。
呼び出し方によってはそれが握り潰されるので、出力も見る。

import pathlib
import subprocess
import sys

def test_全テストファイルが単体で走る():
    for f in sorted(pathlib.Path("tests").glob("test_*.py")):
        r = subprocess.run([sys.executable, str(f)],
                           capture_output=True, text=True, timeout=180)
        assert r.returncode == 0, f"{f} が失敗:\n{r.stdout[-500:]}"
        assert "no tests ran" not in r.stdout, f"{f} は0件で緑になっている"

この番人自身も、わざと __main__ を消して赤くなるのを確認しておく(形①)。


4つに共通していたこと

緑だった本当の理由
検査したい条件ではなく、別の緩い条件が成立していた
設定値のコピーで、変えたら人が書き換えるだけ
差し替えが効かず、本番の設定を読んでいた
そもそも1件も実行されていなかった

どれも**「テストが緑」は事実**だった。嘘は「だから性質が守られている」のほう。

緑は「壊れていない」ではなく「今回は落ちなかった」しか言っていない。

明日からやる順番

  1. 安全に関わるテストを1つ選び、バグを戻して赤くなるのを見る。 1分で終わる
  2. 期待値に設定ファイルと同じ数字を書いている箇所を探す。性質に書き換える
  3. def f(path=CONST) の形を探す。呼び出し時に解決に直す
  4. テストファイルが単体で起動できて、0件で緑にならないことを検査する
  5. 不変条件は本番の設定を読んで検査する

1 だけでも、空振りはかなり減る。私はこれを一度もやっていなかったから、
2日で3件、その後さらに1件出てきた。

おわりに

テストを書くのは速くなった。AIに頼めば、それらしいテストがいくらでも出てくる。
だからこそ「それが本当に何かを守っているか」を確かめる仕事だけが残る。

自分のテストを見て、こう聞いてみるといい。

「このテストは、どのバグを入れたら赤くなるんだろう?」

答えが出てこないなら、たぶん何も守っていない。


note でも書いています

このシリーズ以外に、AIエージェントの運用から広がった政治・経済・メディア構造の話を
note で書いています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?