Pythonテスト #6 テスト設計 — 良いテストとカバレッジの読み方

テストが数百個あるのに、デプロイのたびにバグが漏れるチームがあります。カバレッジは 90% を超えているのに、肝心の顧客が踏むバグはテストが捕まえられません。こうしたチームのテストコードを開いてみると、共通のパターンが見えます。検証なしで呼び出すだけのテスト、実装の内部をそのまま書き写したテスト、ある日は通ってある日は失敗するテストです。ここまでのシリーズでテストを「書く道具」を身につけたので、今回の記事ではテストの量ではなく質を決める設計原則を整理し、カバレッジの数字をどう読むべきかまで扱います。

AAAパターン: テストの基本骨格 #

良いテスト関数は 3 つの区間に分かれます。Arrange (準備)、Act (実行)、Assert (検証) です。

AAAが目に見えるテスト
def test_apply_coupon_reduces_total():
    # Arrange: テスト対象と入力を準備
    cart = Cart()
    cart.add(Item("キーボード", price=50_000))
    coupon = Coupon(discount_rate=0.1)

    # Act: 検証したい動作を 1 回実行
    total = cart.checkout(coupon)

    # Assert: 結果を確認
    assert total == 45_000

コメントを必ず付ける必要はありませんが、3 つの区間が空行ででも区切られているテストは、失敗したときに読みやすいです。準備が長くて本題が見えないなら、#2 のフィクスチャに切り出せという信号です。

「テスト 1 つに検証 1 つ」という原則も一緒に付いてきます。この原則の本当の意味は「assert 1 行」ではなく「動作 1 つ」です。1 つの動作の結果を確認するのに assert が 3 行必要なら、3 行書けばよいのです。避けるべきは、互いに異なる 2 つの動作を 1 つのテストにつなげて書くことです。たとえばカートへの追加と削除を 1 つのテストで順番に検証すると、前の検証が失敗したとき後ろの動作は実行すらされず、失敗レポートが半分しか出ません。2 つに分ければそれぞれ独立して失敗し、テスト名を見るだけでどの動作が壊れたかわかります。

実装ではなく動作をテスト #

リファクタリングのたびにテストがごっそり壊れるなら、テストが実装に縛られているという信号です。

🚫 実装に縛られたテスト
def test_get_user_uses_cache(mocker):
    service = UserService()
    spy = mocker.spy(service, "_load_from_db")
    service.get_user(1)
    service.get_user(1)
    spy.assert_called_once_with(1)   # 内部メソッドの呼び出し回数を検証

_load_from_db という内部メソッドの名前が変わったりキャッシュ戦略が変わったりすると、外から見える動作がそのままでも、このテストは壊れます。観察可能な結果を検証すれば、リファクタリングでも生き残ります。

✅ 動作を検証
def test_get_user_returns_same_user_for_same_id():
    service = UserService()
    first = service.get_user(1)
    second = service.get_user(1)
    assert first == second

判断基準は 1 つです。実装を変えてもユーザーから見える動作が同じなら、テストも通らなければなりません。

テストダブルの用語整理 #

#4 で mock と monkeypatch を道具として身につけましたが、用語を一度整理しておくとコードレビューでの会話が速くなります。本物のオブジェクトの代わりを務めるものを総称して テストダブル (test double) と呼びます。

用語役割
dummyシグネチャを埋めるだけで実際には使われない引数の場所に渡す None
stub決められた値を返す常に 200 を返す偽のレスポンス
fake単純化された実装インメモリ SQLite、dict ベースのストア
mock呼び出しの有無と引数まで検証assert_called_once_with(...)

実務の基準はこうです。結果 (状態) を直接確認できるなら、stub や fake のほうがテストが壊れにくいです。mock の呼び出し検証は、「メールを送ったか」のように結果を別の方法で観察できないときに限って使います。呼び出し検証を乱発すると、上で見た「実装に縛られたテスト」になるからです。

フレーキーテスト: ときどきだけ失敗するテスト #

同じコードなのに、ある実行では通り、ある実行では失敗するテストをフレーキー (flaky) テストと呼びます。原因はほとんどの場合、次の 3 つのどれかです。

原因典型的な症状対応
時間依存深夜 0 時直前、月末にだけ失敗monkeypatch や freezegun で時間を固定
順序依存単独実行は通るが全体実行は失敗グローバル状態を除去、フィクスチャで分離
外部依存ネットワーク状態によって失敗#5 の responses と fake で遮断

順序依存を先回りして見つけるには、pytest-randomly プラグインが有用です。実行のたびにテストの順序をランダムにシャッフルしてくれるので、隠れていた順序依存が早期に表面化します。そして最悪の対応は「再実行すれば通るから放っておく」です。赤信号を無視する習慣がつくと、本物のバグが送る信号も一緒に無視するようになります。フレーキーテストは発見したら即座に原因を直すか、直せないならひとまず隔離 (skip + Issue 登録) するほうがましです。

pytest-covでカバレッジ測定 #

テストがコードのどこを実行したかを測る道具がカバレッジです。pytest では pip install pytest-cov でインストールする pytest-cov プラグインを使います。

pytest --cov=myapp --cov-report=term-missing
出力例
Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
myapp/cart.py        40      4    90%   52-55
myapp/coupon.py      18      0   100%
-----------------------------------------------
TOTAL                58      4    93%

ここで重要なのは 93% という数字ではなく Missing 列です。52〜55 行目がどんなコードか開いてみると、「エラー処理の経路を一度もテストしていなかった」といった事実が明らかになります。--cov-branch オプションを追加すると、行単位ではなく分岐単位で測定してくれるので、有効にすることをおすすめします。

カバレッジの数字の落とし穴 #

ラインカバレッジ 100% は検証 100% ではありません。

カバレッジ100%、検証0%
def test_discount():
    apply_discount(10_000, rate=0.1)   # assert がない

このテストは関数のすべての行を実行するのでカバレッジには計上されますが、結果が間違っていても通ります。カバレッジは「実行したか」だけを測り、「確認したか」は測らないからです。

分岐と境界もすり抜けます。

行は全部実行されたが
def fee(age: int) -> int:
    discount = 0
    if age >= 65:
        discount = 2_000
    return 5_000 - discount

fee(70) を 1 つテストするだけで全行が実行され、ラインカバレッジは 100% になります。しかし 65 歳未満の経路は一度も検証されていません。--cov-branch を有効にすればこの漏れは捕まえてくれますが、条件を age > 65 と書き間違えた off-by-one バグは、境界値 (64 歳、65 歳) を直接テストして初めて表に出ます。

そのため、カバレッジの数字をチーム目標にすると副作用が生まれます。数字が KPI になった瞬間、assert のないテスト、getter を叩くだけのテストが増え、数字は上がるのに品質はそのままという状態になります。カバレッジは「テストが届いていない場所を探す地図」として使い、強制基準は「新しく追加したコードの 80%」のようなゲート程度にとどめるほうが健全です。

どこまで書くか: コスト対価値が高いところから #

すべてのコードに同じ密度でテストを書く必要はありません。投資の優先順位はこうです。

  1. バグが出た場所: 一度壊れた場所はまた壊れます。バグを直すとき再現テストを先に書く習慣が、最も利回りが高いです
  2. 境界値: 0、空のリスト、最大値、境界の直前と直後の値
  3. 核心のドメインロジック: お金の計算、権限の判定のように、間違えるとコストが大きい場所

逆に、単純な委譲コード、フレームワークがすでに保証している動作、まもなく捨てるプロトタイプは後回しです。テストも保守の対象なので、価値の低いテストを積み上げるのは資産ではなく、負債を増やすことになります。

まとめ #

  • テストは Arrange、Act、Assert の 3 区間で構成します
  • テスト 1 つに動作 1 つ、assert の行数は柔軟に考えます
  • 実装の内部ではなく観察可能な動作を検証します
  • テストダブルは dummy、stub、fake、mock に区分し、mock の呼び出し検証は控えめに使います
  • フレーキーの原因は時間、順序、外部依存で、再実行でごまかさないことです
  • カバレッジは数字より Missing 列と分岐を見る地図であり、それ自体が目標ではありません
  • 投資の優先順位はバグが出た場所、境界値、核心ドメインです

次回 (#7 CI 連携) では、GitHub Actions でテストを自動実行し、カバレッジをレポートするパイプラインを作りながらシリーズを締めくくります。

X