Pythonテスト #4 mockとmonkeypatch: 制御できないものを制御する

現在時刻を読む関数、乱数を取り出す関数、外部 API を呼び出す関数は、実行のたびに結果が変わります。昨日通ったテストが今日壊れ、自分のマシンで通ったテストが CI で壊れます。今回はこうした 制御できない依存性をテストの中で制御する 2 つのツールmonkeypatchunittest.mock を扱います。

  • #1 pytest 入門
  • #2 フィクスチャ
  • #3 parametrize とマーカー
  • #4 mock と monkeypatch ← 今回
  • #5 外部世界のテスト
  • #6 テスト設計とカバレッジ
  • #7 CI 連携

そのままではテストできない関数 #

次の関数をテストすると考えてみます。

discount.py: 時間に依存する関数
from datetime import datetime

def night_discount(price: int) -> int:
    """夜 10 時以降の注文は 10% 割引。"""
    now = datetime.now()
    if now.hour >= 22:
        return int(price * 0.9)
    return price

この関数の結果は テストを実行する時刻 によって変わります。昼間に回すと割引の分岐は永遠に実行されず、夜 10 時以降に回すと反対側の分岐が死にます。乱数、環境変数、ネットワーク呼び出しも同じ問題を生みます。解決の方向は 1 つです。テストが実行されている間だけ、その依存性を自分が決めた値に差し替える ことです。

monkeypatch: pytest 標準の差し替えツール #

monkeypatch は pytest が標準で提供するフィクスチャです。#2 フィクスチャ で見たように、テスト関数の引数として受け取るだけで使えます。重要なのは テストが終わると変更したものをすべて自動で元に戻す という点です。

setattr で時間を固定 #

test_discount.py: 時刻を固定
from datetime import datetime
import discount

class FakeDatetime:
    @classmethod
    def now(cls):
        return datetime(2026, 7, 12, 23, 0)   # 夜 11 時に固定

def test_night_discount(monkeypatch):
    monkeypatch.setattr(discount, "datetime", FakeDatetime)
    assert discount.night_discount(10000) == 9000

monkeypatch.setattr(対象オブジェクト, "属性名", 代替物) という形です。discount モジュールの中の datetime を偽物に差し替えたので、関数が見る現在時刻は常に夜 11 時です。テストが終われば元の datetime に戻ります。

setenv と chdir #

環境変数と作業ディレクトリも同じ方法で制御します。

環境変数とディレクトリの差し替え
def test_api_key_required(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert load_config().api_key == "test-key-123"

def test_missing_key(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(KeyError):
        load_config()

def test_reads_local_file(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)               # 一時ディレクトリへ移動
    (tmp_path / "data.txt").write_text("hello")
    assert read_data() == "hello"

os.environ を直接書き換えると次のテストまで汚染されますが、monkeypatch.setenv はテスト単位で隔離されます。環境変数 1 つのせいでテストの実行順序によって結果が変わる事故を、根本から防げます。

Mock と MagicMock: 記録する偽オブジェクト #

monkeypatch は「何に差し替えるか」を自分で作る必要があります。標準ライブラリ unittest.mockMock は、その代替物をその場で作ってくれるツールです。

Mock の 3 つの能力
from unittest.mock import Mock

fake = Mock()

# 1. return_value: 呼び出すと決めた値を返す
fake.get_user.return_value = {"id": 1, "name": "カーティス"}
assert fake.get_user(1) == {"id": 1, "name": "カーティス"}

# 2. 呼び出し記録: どう呼ばれたかを全部記憶
fake.get_user.assert_called_once_with(1)
assert fake.get_user.call_count == 1

# 3. side_effect: 例外を投げたり呼び出しごとに違う値を返したり
fake.fetch.side_effect = TimeoutError("接続失敗")
fake.parse.side_effect = [10, 20, 30]   # 呼び出し順に返す

side_effect に例外を入れると、「外部サービスが落ちたとき自分たちのコードはどう反応するか」をテストできます。実際には再現が難しい障害状況を 1 行で作れるわけです。MagicMockMock__len____iter__ のようなマジックメソッドのサポートを足したバージョンで、patch がデフォルトで作ってくれるのも MagicMock です。日常的には 2 つを区別せずに使っても問題ありません。

patch の核心の罠: 定義された場所ではなく使う場所をパッチ #

unittest.mock.patch は指定したパスのオブジェクトを MagicMock に差し替えてくれます。ところがここに、mock を初めて使う人の半分が引っかかる罠 があります。

weather.py: requests.get を import して使用
from requests import get

def today_temp(city: str) -> float:
    resp = get(f"https://api.example.com/weather/{city}")
    return resp.json()["temp"]

このコードをテストしながら requests.get をパッチするとどうなるでしょうか。

🚫 パッチしたのに本物のネットワーク呼び出しが出る
from unittest.mock import patch

def test_today_temp_wrong():
    with patch("requests.get") as fake_get:        # ✗ 効果なし
        today_temp("seoul")                         # 本物の API 呼び出しが発生

weather.pyfrom requests import get で関数を 自分のモジュールの名前空間にコピー しています。requests.get を差し替えても、weather.get というコピーはそのまま本物の関数を指しています。パッチの対象は その名前が定義された場所ではなく、テストしたいコードがその名前を探して使う場所 でなければなりません。

✅ 使う場所をパッチ
from unittest.mock import patch

def test_today_temp():
    with patch("weather.get") as fake_get:
        fake_get.return_value.json.return_value = {"temp": 28.5}
        assert today_temp("seoul") == 28.5
        fake_get.assert_called_once_with("https://api.example.com/weather/seoul")

fake_get.return_value は「偽の get が返したレスポンスオブジェクト」で、そのオブジェクトの json() が返す値をさらに指定したものです。最後の行の assert_called_once_with正確に 1 回、正確にその URL で呼ばれたか を検証します。呼び出し自体がなかったことを確認するなら assert_not_called、複数の呼び出しのうち 1 つでも一致すればよい場合は assert_any_call を使います。

同じことは monkeypatch でもできます。monkeypatch.setattr("weather.get", fake_get) のように文字列のパスを受け取る形もサポートされているので、pytest スタイルを保ちたいなら monkeypatch + Mock の組み合わせが自然です。

mock が 3 個を超えたら設計を疑う #

mock は便利なぶん乱用しやすいです。危険信号は明確です。

🚫 実装をそのまま写したテスト
def test_order_process():
    with patch("shop.db.get_user") as m1, \
         patch("shop.db.get_cart") as m2, \
         patch("shop.payment.charge") as m3, \
         patch("shop.mailer.send") as m4:
        m1.return_value = ...
        m2.return_value = ...
        process_order(user_id=1)
        m3.assert_called_once()
        m4.assert_called_once()

このテストは「注文が正しく処理されるか」ではなく、関数が内部でこの 4 つをこの順序で呼ぶか を検証しています。動作はそのままで内部の呼び出し構造だけをリファクタリングしてもテストが壊れます。テストが実装に結合しているわけです。mock が 3 つを超えたら、関数が多くのことをやりすぎているという設計上のシグナルとして読むのが良いです。外部との境界 (ネットワーク、時間、乱数) だけを mock で塞ぎ、残りは本物のオブジェクトでテストするのが基本原則です。

mock の代わりに fake: 自作する偽物 #

mock の設定が長くなるなら、いっそ 単純な偽実装 (fake) を自分で作る選択肢があります。

✅ fake リポジトリ
class FakeUserRepo:
    def __init__(self):
        self.users: dict[int, str] = {}

    def save(self, user_id: int, name: str) -> None:
        self.users[user_id] = name

    def find(self, user_id: int) -> str | None:
        return self.users.get(user_id)

def test_register():
    repo = FakeUserRepo()
    register_user(repo, user_id=1, name="カーティス")
    assert repo.find(1) == "カーティス"

fake は実際に動作する最小限の実装なので、呼び出しの順序ではなく 結果の状態 を検証することになります。リファクタリングに強く、複数のテストで再利用でき、読む人が設定コードを解読する必要もありません。呼び出しの有無そのものが検証対象のとき (メール送信、決済リクエスト) は mock が、データをやり取りする協力オブジェクトのときは fake が合います。

まとめ #

今回扱った内容です。

  • 時間、乱数、外部 API に依存する関数はそのままではテストできないので、テストの間だけ差し替えます
  • monkeypatchsetattrsetenvchdir で差し替え、テストが終わると自動で復元します
  • Mockreturn_value で戻り値を決め、side_effect で例外と順次返却を作り、呼び出しをすべて記録します
  • patch は名前が定義された場所ではなく 使う場所 を対象に指定する必要があります
  • assert_called_once_withcall_count で呼び出し方を検証します
  • mock が 3 つを超えたら、実装に結合したテストになっているという設計上のシグナルです
  • データをやり取りする協力オブジェクトは、mock の代わりに fake 実装のほうがリファクタリングに強いです

次回 (#5 外部世界のテスト) では、ここから一歩進んで、データベースや HTTP API のように 本物の外部世界に触れるコード をテストする戦略を扱います。#3 parametrize とマーカー で見たマーカーで遅いテストを分離する方法も一緒に登場します。

X