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件のとき終了コード 5(ExitCode.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分で終わる
- 期待値に設定ファイルと同じ数字を書いている箇所を探す。性質に書き換える
-
def f(path=CONST)の形を探す。呼び出し時に解決に直す - テストファイルが単体で起動できて、0件で緑にならないことを検査する
- 不変条件は本番の設定を読んで検査する
1 だけでも、空振りはかなり減る。私はこれを一度もやっていなかったから、
2日で3件、その後さらに1件出てきた。
おわりに
テストを書くのは速くなった。AIに頼めば、それらしいテストがいくらでも出てくる。
だからこそ「それが本当に何かを守っているか」を確かめる仕事だけが残る。
自分のテストを見て、こう聞いてみるといい。
「このテストは、どのバグを入れたら赤くなるんだろう?」
答えが出てこないなら、たぶん何も守っていない。
note でも書いています
このシリーズ以外に、AIエージェントの運用から広がった政治・経済・メディア構造の話を
note で書いています。