【第4回】Python pytest 入門 — テスト設計のベストプラクティスとよくある落とし穴・応用Tips

プログラミング

前回(第3回)は fixture と conftest.py を使って、テストの前準備を使い回す話を書きました。シリーズ最終回の今回は、実際に自分がテストを書くようになってから一番つまずいた「モック」と「カバレッジの測定方法」、そしてテスト設計で気をつけていることを一気にまとめます。

きっかけは、自作の Lambda 関数に後からテストを足そうとしたときに「外部APIを叩く部分どうすんの?」でフリーズしたことでした。あと、カバレッジを出したら 92% と表示されて謎に満足したものの、よく見たら肝心のエラーハンドリングが全部通ってなかったという。数字だけ見てると普通に騙されます。

モック(pytest での実装パターン)

pytest でモックする方法、調べると情報がバラバラに出てきて最初かなり混乱しました。整理すると大きく3つあります。

  • monkeypatch:pytest 標準の組み込み fixture。属性・環境変数・辞書の差し替えが得意で、テスト終了後に自動で元に戻る
  • unittest.mockpatch / MagicMock):標準ライブラリ。呼び出し回数や引数の検証までやりたいとき
  • pytest-mock:上の unittest.mockmocker fixture としてラップしたサードパーティ製。with 文やデコレータのネストから解放される、という文脈で紹介されています

環境変数を差し替えたいだけ、みたいな軽い用途なら monkeypatch が一番すっきりします。

import os

def build_endpoint():
    return f"https://{os.environ['API_HOST']}/v1/messages"

def test_build_endpoint(monkeypatch):
    monkeypatch.setenv("API_HOST", "example.test")
    assert build_endpoint() == "https://example.test/v1/messages"

一方で「この関数、ちゃんと1回だけ呼ばれた?」「引数合ってる?」を確認したいときは Mock の出番。ここは pytest というより標準ライブラリの話ですが、テストを書くうえでは避けて通れません。

from unittest.mock import MagicMock

class Notifier:
    def send(self, message: str) -> str:
        raise RuntimeError("本番の送信処理。テストでは絶対に呼びたくない")

def notify_all(notifier, users):
    return [notifier.send(f"hello {u}") for u in users]

fake = MagicMock(spec=Notifier)  # ここ注意: spec を付けないとタイポし放題になる
fake.send.return_value = "ok"

print(notify_all(fake, ["alice", "bob"]))
print("呼ばれた回数:", fake.send.call_count)
print("呼ばれた引数:", fake.send.call_args_list)

fake.send.assert_any_call("hello alice")

try:
    fake.snd("typo")
except AttributeError as e:
    print("spec のおかげで検出:", e)

spec=(あるいは autospec=True)を付けるかどうかは結構重要で、付けないと存在しないメソッドを呼んでもモックが笑顔で応答してしまいます。実装側のメソッド名をリネームしたのにテストが緑のまま、という事故はこれで防げます。

リトライ処理は side_effect でテストする

side_effect にリストを渡すと、呼び出しごとに順番に返してくれます。例外クラスを混ぜられるので、リトライやフォールバックのテストがかなり書きやすくなりました。

from unittest.mock import Mock

def fetch_with_retry(client, retries=3):
    last_error = None
    for _ in range(retries):
        try:
            return client.get()
        except TimeoutError as e:
            last_error = e
    raise last_error

client = Mock()
client.get.side_effect = [TimeoutError("1回目"), TimeoutError("2回目"), {"ok": True}]

print("結果:", fetch_with_retry(client))
print("実際に呼ばれた回数:", client.get.call_count)

client2 = Mock()
client2.get.side_effect = TimeoutError("ずっと失敗")
try:
    fetch_with_retry(client2, retries=2)
except TimeoutError as e:
    print("2回失敗して例外:", e, "/ 呼び出し回数:", client2.get.call_count)

patch する場所を間違える、という定番の罠

自分が一番ハマったのがこれです。requests.get を使っているモジュールをテストするとき、patch("requests.get") ではなく patch("myapp.api.requests.get") のように「使っている側の名前空間」を指定する必要があります。

# myapp/api.py
from mylib import fetch_user

def handler(user_id):
    return fetch_user(user_id)["name"]

# tests/test_api.py
from unittest.mock import patch

def test_handler():
    # NG: patch("mylib.fetch_user") … from import 済みなので効かないことがある
    with patch("myapp.api.fetch_user", return_value={"name": "nyanchu"}) as m:
        assert handler(1) == "nyanchu"
    m.assert_called_once_with(1)

from x import y した時点で、モジュール側に別名の参照ができてしまうのが理由です。「定義元じゃなくて使う場所をパッチする」と覚えてから、謎の「モックが効かない」現象がほぼ消えました。逆に import mylib して mylib.fetch_user() と呼んでいるなら定義元のパッチで効きます。このへん、正直いまだに一発で当てられないので毎回 print で確かめてます。

カバレッジの測定方法(pytest-cov の実用設定)

測定自体は pytest-cov を入れれば一瞬です。中身は coverage.py なので、細かい設定はそちら側に書くのが結局ラクでした。

pip install pytest-cov

# 最低限
pytest --cov=myapp

# 未カバー行を行番号で出す(これが一番使う)
pytest --cov=myapp --cov-report=term-missing

# 分岐カバレッジも含める
pytest --cov=myapp --cov-branch --cov-report=html

毎回オプションを打つのが面倒なので、pyproject.toml にまとめています。

[tool.pytest.ini_options]
addopts = "-q --cov=myapp --cov-report=term-missing"
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["myapp"]
omit = ["*/migrations/*", "*/__main__.py"]

[tool.coverage.report]
show_missing = true
fail_under = 80
exclude_also = [
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

--cov-branch(または branch = true)は入れておいたほうがいいと思います。行カバレッジだけだと if の「通らなかった側」がカウントされず、数字が実態より甘く出ます。前述の 92% も、branch を有効にしたら 80% 台前半まで落ちました。現実を見せられた気分です。

あとこれは最近知ったのですが、Python 3.12 以降だと COVERAGE_CORE=sysmon を環境変数で指定することで sys.monitoring ベースの(実験的な)計測に切り替えられて、計測のオーバーヘッドがかなり減るらしいです。テスト数が増えてくるほど効いてくるはずなので、CI が遅いと感じたら試す価値はありそう。--cov-report=xml を吐いておけば CI 側のレポートサービスにも渡せます。

「カバレッジ100%」は目的ではない、という話

カバレッジは「テストされていない場所を見つける道具」であって、達成すべきスコアではない、というのが今のところの自分の理解です。その行を通っただけでアサーションしていなければ、カバレッジは 100% でも品質は保証されません。極端な話、全関数をただ呼ぶだけのテストを書けば数字は作れてしまいます。

なので実運用では、こういう見方をしています。

  • 全体の % より、term-missing で出る未カバー行の中身を見る
  • お金・データ削除・外部通知が絡む分岐は、カバレッジに関係なく必ずテストする
  • fail_under はチームの現実値より少し下に置いて、じわじわ上げる
  • 自動生成コードや __init__.py は omit して、ノイズを消す

設計で気をつけていること・応用Tips

テスト設計のベストプラクティスと呼ばれるものはたくさんありますが、初学者の自分が実感として効いたのは次のあたりでした。

  • Arrange / Act / Assert を空行で分ける。これだけでレビュー時の読みやすさが別物になります
  • テスト間で状態を共有しない。順番に依存したテストは、並列実行した瞬間に壊れます
  • テスト名は「条件 → 期待結果」test_handler_1 は3日後の自分に優しくない
  • モックは境界にだけ使う。自作クラスを片っ端からモックすると、リファクタのたびにテストが壊れる置物になります

コマンド面での小ネタもいくつか。pytest -x --lf は「前回落ちたテストだけ、最初の失敗で止める」の組み合わせで、修正ループがかなり速くなります。pytest --durations=10 は遅いテスト上位10件を出してくれるので、CI が重いときの犯人探しに便利。テスト数が増えたら pytest-xdist-n auto で並列化(pytest-cov とも併用できます)、順序依存の事故を見つけたいなら pytest-randomly を入れて毎回シャッフルさせる、という感じです。

最後にモック関連の落とし穴をひとつ。assert mock.called_once と書くと常に成功します。存在しない属性が Mock として返り、それが真と評価されるためです。正しくは mock.assert_called_once()。ちなみに assertassret で始まる名前のタイポは unittest.mock 側が AttributeError にしてくれるのですが、called_once のように接頭辞がないものはすり抜けることがあるようです。自分はこれで丸一日「テストが通るのに動かない」をやりました。

4回書いてみて

結局のところ、pytest の機能そのものより「何をテストするか・何をモックするか」を決める部分のほうが難しい、というのが4回書いてみての感想です。fixture もモックもカバレッジも道具であって、雑に使うと逆にメンテナンスコストになるのがよくわかりました。もっといいやり方がある気がするので、このへんは実プロジェクトで殴られながら更新していく予定です。

GitHub Actions でこのテスト一式を回して、カバレッジの推移を PR に出すところまでやりたいなと思っています。テストの質そのものを測る「ミューテーションテスト」というものがあるらしく、mutmut あたりを触ってみたい気持ちが強い。カバレッジ 100% のコードにわざとバグを入れてテストが落ちるか試す、という発想がなかなか意地悪で好きです。

みなさんはカバレッジの閾値、何%に設定してますか。

※この記事にはプロモーションが含まれます

余談ですが、pytestのカバレッジ計測でCI環境をあれこれ調べてたら、ついでにブログ用のサーバーも見直したくなってお名前.com レンタルサーバーが目に留まりました。

📚 シリーズ「Python pytest 入門」(第4回 / 全4回)

← 前回の記事: 前回の記事はこちら

🎉 このシリーズは今回で完結です!

タイトルとURLをコピーしました