monkeypatch — 環境変数、時刻、ネットワークを制御する
環境変数、時計、乱数、ネットワークに依存するコードのテストを学びます。setenv、delenv、setattr、setitem、chdir、テストごとの自動的な復元(undo)、「参照される場所でパッチする」ルール、そしてパッチ自体を不要にする依存性の注入(Dependency Injection)を扱います。
どうやって学びますか?
解決する問題
以下は、アプリケーションがデバッグモードで動作するかどうかを判定する関数です。環境変数を読み取っています:
settings.py:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"test_settings.py:
from settings import debug_enabled
def test_debug_is_off_by_default():
assert debug_enabled() is False端末でpytest -qを実行するとパスします。しかし、誰かがAPP_DEBUG=1をエクスポートしているマシン(同僚のノートPCや、デバッグ用に設定されたCIサーバーなど)で再度実行すると、まったく同じコードに対する同じテストが失敗します:
$ pytest -q
. [100%]
1 passed in 0.01s
$ APP_DEBUG=1 pytest -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_debug_is_off_by_default _________________________
def test_debug_is_off_by_default():
> assert debug_enabled() is False
E assert True is False
E + where True = debug_enabled()
test_settings.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_settings.py::test_debug_is_off_by_default - assert True is False
1 failed in 0.01sコード側は何も変わっていません。変わったのは、それを取り巻く外部の世界です。環境変数、現在時刻、乱数、カレントディレクトリ、ネットワーク — これらを読み取るコードは、いつどこで実行されるかによって異なる結果を返します。それらを制御していないテストは、自分の書いたコードだけをテストしているわけではありません。
テストには「このテストの実行中だけは、外部環境をこの状態にする」と宣言し、終わったらすべてを元通りに戻す仕組みが必要です。それを行うのが、組み込みのmonkeypatchフィクスチャです。
この章を終える頃には
monkeypatch.setenvとmonkeypatch.delenvを使って、1つのテストのためだけに環境変数を設定・削除できるようになる- オブジェクトと名前、または
"module.attr"形式の文字列を使い、monkeypatch.setattrで関数を置き換えることができるようになる monkeypatch.setitemで辞書の1つのキーを変更し、monkeypatch.chdirでカレントディレクトリを変更できるようになる- テストの終了時にすべての変更が元に戻ることを確認・検証できるようになる
- 名前が参照される場所でパッチを適用し、
random.randintをパッチしてもfrom random import randintに効かない理由を説明できるようになる - 時計や乱数関数を引数として渡すことで、パッチをまったく必要としない設計ができるようになる
前提条件: 組み込みフィクスチャ。
テストを書く前に
このようなコードを扱う場合、最初のdef test_を書く前に大半の作業が終わっています。以下の4つの問いを順番に考えていきましょう。
1. その関数は何を約束しているか — そして引数として渡していない何を読み取っているか? 関数を読み、引数以外の外部から何かを取得しているすべての行をチェックします。os.environ、datetime.now()、random、相対ファイルパス、モジュールレベルの辞書、ネットワーク呼び出しなどです。これらはすべて「隠れた入力」です。関数の約束とは「これらの隠れた入力が与えられたとき、これを返す」というものであり、テストではすべての隠れた入力を固定しなければなりません。そうでなければ、実行マシンにたまたま設定されていた内容をテストしているだけになってしまいます。
2. 何を準備しておく必要があるか? いつもの準備です。pytestがインストールされた仮想環境、そしてテストから対象モジュールをインポートできる状態(テストファイルの隣に配置されているか、プロジェクトがインストールされていること)が必要です。そして今回のトピックでは、隠れた入力ごとに1つの判断を行います:
- 環境変数 —
setenvで設定するか、raising=Falseを指定してdelenvで削除する - ネットワーク、ディスク、乱数源にアクセスする関数 — それを呼び出しているモジュール内で、
setattrを使って置き換える - モジュールレベルの辞書 —
setitemで1つのキーを変更する - 相対パス —
tmp_pathへとchdirする - これから書くコード内の時計や乱数選択 — 代わりに引数として渡すようにする
3. どのようなケースをテストするか? この章の最後で構築する設定ローダーを例に考えてみましょう。このローダーは、デフォルト値、オプションのapp.cfgファイル、そしてAPP_*環境変数の順に読み取ります。コードを書く前の計画は次のようになります:
| ケース | テスト中の外部環境 | 期待値 | |---|---|---| | 何も設定されていない | 空のフォルダ、APP_*変数はなし | ポート8000、デバッグFalse、リージョン"bd" | | ファイルがデフォルト値を上書き | app.cfgにport = 9000が含まれる | ポート9000 | | 環境変数がファイルを上書き | app.cfgに9000、APP_PORT=7000 | ポート7000 | | デフォルト値の1つが変更された | DEFAULTS["region"]が"ae" | リージョン"ae" | | 無効な値 | APP_PORT=70000 | "out of range"を含むValueError | | 読み取り関数を差し替える | read_fileが{"port": "1234"}を返す | ポート1234 | | シェル側のノイズ | pytestを実行する前にAPP_PORT=5がエクスポートされている | 上記のすべての行が依然として成り立つ |
最後の行は多くの人が忘れがちな点です。これは独立したテストではなく、テストスイート全体の特性です。そして、環境変数を設定するテストだけでなく、すべてのテストがまず変数をクリアしなければならない理由でもあります。
4. テストすべきでないものは何か。 os.environが文字列を保存することや、urllibがページを取得できることをテストしてはいけません。それはPythonの役割であり、すでにテストされています。単体テストから本物のネットワークにアクセスさせてはいけません。時間がかかり、自分のコードとは無関係な理由で失敗し、さらに実際の天気も変わってしまいます。そして、フェイクそのものをテストするようなassertは絶対に書かないでください。以下のテストはパスしますが、何も証明していません:
import weather
def test_pointless(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.fetch_temperature("Dhaka") == 35. [100%]
1 passed in 0.01sこれはパッチが適用されたことを確認しているだけであり、自分のコードが動作していることを確認しているわけではありません。フェイクが存在する理由は、その上で他のコード(後述のadviceなど)をテストできるようにするためです。置き換えた対象だけをassertで検証しているようなテストなら、削除してください。
以降の節では、この計画をツールごとに1つずつ実装していきます。
setenvとdelenv — テストごとの環境変数
monkeypatchはtmp_pathと同じようなフィクスチャです。テスト関数のパラメータで名前を指定して要求します。少し拡張したsettings.pyに対する4つのテストを見てみましょう:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"
def api_port():
return int(os.environ.get("APP_PORT", "8000"))from settings import api_port, debug_enabled
def test_debug_on(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert debug_enabled() is True
def test_debug_off_by_default(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert debug_enabled() is False
def test_port_from_env(monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
assert api_port() == 9000
def test_port_default(monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
assert api_port() == 8000$ pytest -q
.... [100%]
4 passed in 0.01s
$ APP_DEBUG=1 APP_PORT=1234 pytest -q
.... [100%]
4 passed in 0.01s重要なのは2回目の実行です。シェル側でエクスポートされた値によってこれらのテストのうち2つが壊れるはずでしたが、各テストが必要な環境を明確に指定しているため、4つすべてがパスし続けます。
setenv(name, value)は変数を設定します。delenv(name)は変数を削除します — そしてデフォルトでは、その変数が元々存在しなかった場合にKeyErrorを送出します。「未設定」のケースをテストする際には、以前設定されていたかどうかは関係なく、今設定されていないことだけが重要なので、ほとんどの場合はraising=Falseを指定することになります。
値は文字列です。環境変数はテキストのみを保持し、os.environも同様です。"9000"を9000に変換するのは自分のコードの役目であり、その変換処理こそがテストする価値のある部分である場合がよくあります。
すべてが元通りに戻る — その検証
monkeypatchはテスト終了時に変更内容を元に戻すと説明しました。その証拠を確認してみましょう:
import os
def test_first(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))pytest -q -s(-sはprintの出力をそのまま表示させるオプションです):
inside first : 1
.inside second: None
.
2 passed in 0.01s2番目のテストはmonkeypatchを要求していませんが、何も見えていません — 変数は消えています。os.environに直接書き込んだ場合に何が起きるかと比較してみてください:
import os
def test_first():
os.environ["APP_DEBUG"] = "1"
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))inside first : 1
.inside second: 1
.
2 passed in 0.01s値が次のテストへと漏洩し、同じ実行内のそれ以降のすべてのテストにも漏洩してしまいました。これは、テストを単独で実行するとパスするのに、他のテストと一緒に実行すると失敗する(あるいはさらに厄介なことに、その逆になる)というバグの原因になります。
この復元(undo)処理に魔法のような仕組みはありません。各変更の前に、monkeypatchは古い値(または値が存在しなかったという事実)を記録し、ティアダウン時に記録されたすべての値を逆順に戻します。ティアダウンはテストがパスした場合も、失敗した場合も、例外を送出した場合も実行されるため、途中でアサーションが失敗しても環境が汚れたまま放置されることはありません。手作業でos.environ[...] = ...を書き、テストの末尾に手作業でクリーンアップ処理を書いた場合、途中のアサーション失敗によってまさにそのクリーンアップ処理がスキップされてしまいます。
setattr — 1つのテストのために関数を置き換える
環境変数はまだ扱いやすいケースです。より困難なのは、ネットワークと通信するコードです:
weather.py:
import json
import urllib.request
def fetch_temperature(city):
url = f"https://api.example.com/weather?city={city}"
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)["temp_c"]
def advice(city):
temp = fetch_temperature(city)
if temp >= 30:
return "Hot: carry water"
if temp <= 10:
return "Cold: take a jacket"
return "Mild"重要なロジックはadviceの中にあります。しきい値やメッセージです。このままテストすると、テストはサーバー、ネットワーク接続、そして今日の天気に依存してしまいます。ネットワークのないマシンで実行すると、次のようになります:
$ pytest -q --tb=line test_real.py
F [100%]
=================================== FAILURES ===================================
E socket.gaierror: [Errno -5] No address associated with hostname
During handling of the above exception, another exception occurred:
E urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
/usr/lib/python3.12/urllib/request.py:1347: urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
1 failed in 0.01smonkeypatch.setattrを使うと、1つのテストの間だけ、fetch_temperatureを自分が制御可能なものに差し替えることができます:
import weather
def test_hot_day(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.advice("Dhaka") == "Hot: carry water"
def test_cold_day(monkeypatch):
monkeypatch.setattr("weather.fetch_temperature", lambda city: 4)
assert weather.advice("Oslo") == "Cold: take a jacket".. [100%]
2 passed in 0.01s対象を指定する方法は2つあり、どちらも同じ動作をします:
setattr(weather, "fetch_temperature", new)— オブジェクトを指定し、次に属性名を文字列で指定する形式setattr("weather.fetch_temperature", new)— 1つのドット区切り文字列を指定する形式。pytestがweatherをインポートし、最後の部分を切り離します
テストがすでにモジュールをインポートしている場合はオブジェクト形式を使用し、インポートする必要がない場合は文字列形式を使用します。どちらの方法であっても、現在テストされている対象に注目してください。置き換えたfetch_temperatureではなく、私たちが書いたコードであるadvice内のしきい値とメッセージです。ネットワーク通信も待ち時間もなく、毎回同じ結果が得られます — そして、実際の天気では思い通りにならない35度や4度といった気温を、必要なときに正確に再現できます。
テスト内で単にweather.fetch_temperature = lambda city: 35とするのではなく、なぜsetattrを使うのでしょうか?理由はos.environのときと同じです。通常の代入は元に戻されることがなく、その実行における後続のすべてのテストにフェイクが適用されてしまうからです。
ルール: 参照される場所でパッチする
ここで、誰もが一度は経験するミスを取り上げます。サイコロを振る小さなモジュールです:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6大当たり(jackpot)をテストするために、randintが強制的に6を返すようにします。これはrandomモジュールにあるものなので、そこでパッチしてみます:
import random
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.is_jackpot() is TrueF [100%]
=================================== FAILURES ===================================
_________________________________ test_jackpot _________________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fc1b2fdc9b0>
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
> assert dice.is_jackpot() is True
E assert False is True
E + where False = <function is_jackpot at 0x7fc1b2fcd800>()
E + where <function is_jackpot at 0x7fc1b2fcd800> = dice.is_jackpot
test_dice.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_dice.py::test_jackpot - assert False is True
1 failed in 0.01sパッチは適用されましたが、dice側にはまったく反映されませんでした。テストをあと数回実行すると、たまにパスすることがあります — 本物のrandintが偶然6を出したときです。間違っているのにたまにグリーン(成功)になるテストは、常にレッド(失敗)になるテストよりも厄介です。
原因は、from random import randintが実際に行っている処理にあります。これはrandom.randintへのリンクを作成するわけではありません。インポート時に、その参照を新しい名前dice.randintへとコピーします。その後は2つの名前が同じ関数を指している状態になり、setattrはそのうちの片方しか変更しません:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
Falseroll()が実行されるとき、Pythonはdiceモジュールの中でrandintを探します — つまり、そこが名前の参照される場所(looked up)です。したがって、置き換えるべき場所もそこになります:
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(dice, "randint", lambda a, b: 6)
assert dice.is_jackpot() is True
def test_no_jackpot(monkeypatch):
monkeypatch.setattr("dice.randint", lambda a, b: 3)
assert dice.is_jackpot() is False.. [100%]
2 passed in 0.01s5回続けて実行しても、5回ともパスします。もう1つの修正方法は、dice.pyでのインポート方法を変更することです。import randomとしてrandom.randint(1, 6)を呼び出すようにすれば、呼び出しのたびにrandomモジュール内で参照が行われるため、random.randintをパッチする方法でも機能するようになります:
import random
def roll():
return random.randint(1, 6)
def is_jackpot():
return roll() == 6$ pytest -q # the original test, patching random.randint
. [100%]
1 passed in 0.01sこのルールを一言で表すとこうなります: その関数を呼び出している行を見つけ、どのモジュールからその名前を取得しているかを確認し、その場所で名前をパッチする。
setitem — 辞書の1つのキー
設定値はモジュールレベルの辞書で保持されることがよくあります。辞書全体を置き換えるのは不格好です。setitemを使えば、1つのキーだけを変更し、テスト後に元に戻すことができます:
shop.py:
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
def price_with_tax(amount):
total = round(amount * (1 + SETTINGS["tax_rate"]), 2)
return f"{total} {SETTINGS['currency']}"import shop
def test_tax_free_day(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
assert shop.price_with_tax(100) == "100.0 BDT"
def test_other_currency(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "currency", "INR")
assert shop.price_with_tax(100) == "115.0 INR"
def test_settings_are_back():
assert shop.SETTINGS == {"currency": "BDT", "tax_rate": 0.15}pytest -v:
test_shop.py::test_tax_free_day PASSED [ 33%]
test_shop.py::test_other_currency PASSED [ 66%]
test_shop.py::test_settings_are_back PASSED [100%]
============================== 3 passed in 0.01s ===============================3番目のテストが再びその証拠です。2つのテストがそれぞれキーを変更した後でも、辞書は開始時とまったく同じ状態に戻っています。対になる機能として、monkeypatch.delitem(d, key)は1つのテストの間だけキーを削除します。
chdirとsyspath_prepend
"notes.txt"のような相対パスを開くコードは、それが実行されるディレクトリに依存します。前章で学んだtmp_pathとchdirを組み合わせてみましょう:
notes.py:
from pathlib import Path
def read_notes():
return Path("notes.txt").read_text().splitlines()from pathlib import Path
from notes import read_notes
def test_reads_from_current_directory(tmp_path, monkeypatch):
(tmp_path / "notes.txt").write_text("buy milk\ncall Rina\n")
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
assert read_notes() == ["buy milk", "call Rina"]
def test_import_from_a_new_folder(tmp_path, monkeypatch):
(tmp_path / "helper.py").write_text("ANSWER = 42\n")
monkeypatch.syspath_prepend(tmp_path)
import helper
assert helper.ANSWER == 42.. [100%]
2 passed in 0.01schdirは1つのテストの間だけ作業ディレクトリを移動し、終了後に元の場所に戻します。2番目のテストはsyspath_prependを使う1行でその役割を示しています。フォルダをsys.pathの先頭に追加して、そこに書いたモジュールをインポートできるようにし、テスト終了時に再び削除します — フォルダからプラグインを読み込むようなコードをテストする際に便利です。
さらに良いアプローチ: 依存関係を引数として渡す
パッチを当てる方法は機能しますが、すべてのパッチは、コードが外部の世界に手を伸ばして何かを勝手に掴み取っていることの小さな告白でもあります。中には、簡単にはパッチできないものもあります。datetime.nowがその代表例です:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'その代わりとなるのが、設計上の選択です。関数が依存するものを受け取るようにし、デフォルト値として本物を指定するのです:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21本番コードはis_open()を呼び出し、本物の時計を使います。テスト側は独自の時計を渡します:
from datetime import datetime
from shop_hours import is_open
def test_closed_just_before_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 8, 59)) is False
def test_open_at_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 9, 0)) is True
def test_closed_at_nine_pm():
assert is_open(now=lambda: datetime(2026, 1, 5, 21, 0)) is False... [100%]
3 passed in 0.01sフィクスチャも不要で、元に戻す処理もなく、名前がどこで参照されるかを気にする必要もありません。テストは「8時59分には店は閉まっている」という平易な文章のように読め、重要となる境界の分をピンポイントで検証するのも簡単です。
同じパターンは乱数にも使えます。def pick_winner(customers, choose=random.choice)と定義し、テスト側でchoose=lambda items: items[0]を渡す形です。これは依存性の注入(Dependency Injection)と呼ばれますが、フレームワークは一切不要です。デフォルト引数だけで十分です。自分でコードを書くときはこの方法を使いましょう。変更できない・変更すべきでない既存のコードをテストする場合や、依存関係が何層も深い呼び出しの奥底に埋もれている場合に、monkeypatchを活用してください。
完全な例
これは「テストを書く前に」で計画した設定ローダーです。組み込みのデフォルト値、カレントディレクトリにあるオプションのapp.cfgファイル、そして両方を上書きする環境変数という3つの層で構成されています。計画の各行がそれぞれ1つのテストに対応します。
app_config.py:
import os
from pathlib import Path
DEFAULTS = {"port": "8000", "debug": "0", "region": "bd"}
def read_file(name="app.cfg"):
path = Path(name)
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_config():
# Lowest priority first: defaults, then the file, then the environment.
raw = dict(DEFAULTS)
raw.update(read_file())
for key in raw:
env_name = "APP_" + key.upper()
if env_name in os.environ:
raw[key] = os.environ[env_name]
port = int(raw["port"])
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return {"port": port, "debug": raw["debug"] == "1", "region": raw["region"]}test_app_config.py:
import pytest
import app_config
@pytest.fixture
def clean_env(tmp_path, monkeypatch):
# An empty folder and no APP_ variables: the test starts from nothing.
monkeypatch.chdir(tmp_path)
for name in ("APP_PORT", "APP_DEBUG", "APP_REGION"):
monkeypatch.delenv(name, raising=False)
return tmp_path
def test_defaults(clean_env):
assert app_config.load_config() == {"port": 8000, "debug": False, "region": "bd"}
def test_file_beats_defaults(clean_env):
(clean_env / "app.cfg").write_text("port = 9000\nregion = in\n")
config = app_config.load_config()
assert config["port"] == 9000
assert config["region"] == "in"
def test_env_beats_file(clean_env, monkeypatch):
(clean_env / "app.cfg").write_text("port = 9000\n")
monkeypatch.setenv("APP_PORT", "7000")
assert app_config.load_config()["port"] == 7000
def test_changed_default(clean_env, monkeypatch):
monkeypatch.setitem(app_config.DEFAULTS, "region", "ae")
assert app_config.load_config()["region"] == "ae"
def test_bad_port(clean_env, monkeypatch):
monkeypatch.setenv("APP_PORT", "70000")
with pytest.raises(ValueError, match="out of range"):
app_config.load_config()
def test_reader_can_be_replaced(clean_env, monkeypatch):
monkeypatch.setattr(app_config, "read_file", lambda: {"port": "1234"})
assert app_config.load_config()["port"] == 1234pytest -v:
collected 6 items
test_app_config.py::test_defaults PASSED [ 16%]
test_app_config.py::test_file_beats_defaults PASSED [ 33%]
test_app_config.py::test_env_beats_file PASSED [ 50%]
test_app_config.py::test_changed_default PASSED [ 66%]
test_app_config.py::test_bad_port PASSED [ 83%]
test_app_config.py::test_reader_can_be_replaced PASSED [100%]
============================== 6 passed in 0.01s ===============================注目すべき点が3つあります。
第1に、clean_envフィクスチャ自体がmonkeypatchを使用している点です。フィクスチャとそれを要求するテストは1つのmonkeypatchオブジェクトを共有するため、テスト側でさらに独自のsetenvを上乗せすることができ、両方で行われたすべての変更がテスト終了時にまとめて元に戻されます。
第2に、変数が存在しないことを当てにするのではなく、フィクスチャが明示的に変数を削除している点です。これにより、APP_PORT=5 APP_REGION=xx pytest -qのように環境変数を設定して実行しても、設定せずに実行したときと同じ結果が得られます — 実際に試してみてください。依然として6 passedと表示されます。これは、独立したテストを用意するのではなく設計によって満たされた、計画表の最後の行の要件です。
第3に、最後のテストがapp_config.read_file(load_configが名前を参照するモジュール)をパッチしている点です。両方の関数が同じモジュール内に存在するため、パッチすべき場所はそのモジュールになります。
問題が起きたとき
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' 属性名のタイポ(打ち間違い)です。setattrは既存ではない属性の作成を拒否するため、誰も読み取らない名前を誤って「パッチ」してしまうのを防いでくれます。文字列形式ではAttributeError: 'module' object at weather has no attribute 'fetch_temprature'と少し異なる表現になりますが、意味は同じです。本当に新しい属性を追加したい場合にのみ、raising=Falseを渡してください。
パッチは適用されているのに、本物の関数が実行され続ける エラーは一切出ません。テストが本物の値で失敗するか、運良くパスしてしまいます。名前が参照される場所(dice.randint)ではなく、名前が定義されている場所(random.randint)でパッチしてしまっています。テスト対象コード内のimport文を確認してください。
monkeypatch.delenvによるKeyError: 'APP_DEBUG' 変数が設定されておらず、delenvがデフォルトで例外を送出しています。raising=Falseを追加してください。同様の動作はdelitemやdelattrにも当てはまります。
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly setenv("APP_PORT", 9000)と記述しています。環境変数はテキストなので、"9000"と書いてください。
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' datetime.datetimeのような組み込み型は、そのメソッドを置き換えることができません。is_openの例のように、代わりに時計を引数として渡すようにしてください。
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. scope="module"(または"session")のフィクスチャが、1つのテストの間しか生存しないmonkeypatchを要求しています。フィクスチャを関数スコープのままにしておくか、より広いスコープのフィクスチャ内部でpytest.MonkeyPatch.context()を使用して自分で復元(undo)を管理してください。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -q -sで実行します。2つのprint呼び出しは何を表示しますか(事前にCITYは設定されていないものとします)?
import os
def test_one(monkeypatch):
monkeypatch.setenv("CITY", "Dhaka")
print(os.environ.get("CITY", "none"))
def test_two():
print(os.environ.get("CITY", "none"))- ADhaka、次にエラー: KeyError: 'CITY'
- Bnone、次にnone
- CDhaka、次にnone
- DDhaka、次にDhaka
このテストはほとんどの場合失敗し、たまにパスします。何が間違っていますか?
# dice.py
from random import randint
def roll():
return randint(1, 6)
# test_dice.py
import random
import dice
def test_roll_six(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.roll() == 6- A`setattr`ではなく`setitem`を使う必要がある
- B`lambda`は2つのパラメータを取るべきではない
- C`monkeypatch`は標準ライブラリのモジュールをパッチできない
- Dパッチは`random`に適用されていますが、`roll()`はその名前を`dice`の中で参照しています — `dice.randint`をパッチする必要があります
後続のテストが再び0.15を参照できるようにするには、...をどの行に置き換えるのが最も適切ですか?
# shop.py
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
# test_shop.py
import shop
def test_tax_free(monkeypatch):
... # set tax_rate to 0.0 for this test only- Amonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Bmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dshop.SETTINGS["tax_rate"] = 0.0
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
greeter.pyを作成し、以下を返す関数greeting(name)を実装してください:
- 12:00より前なら
"Good morning, <name>"、12:00から18:00までなら"Good afternoon, <name>"、それ以降なら"Good evening, <name>" - 環境変数
GREETER_LOUDが"1"の場合は大文字で返す
また、この関数が文言を読み取るモジュールレベルの辞書TEMPLATES = {"morning": "Good morning", ...}も定義します。
次に、以下を行うtest_greeter.pyを作成してください:
setenvとdelenv(..., raising=False)を使ってloud(大文字)とquiet(通常)のケースをテストし、シェル側でGREETER_LOUDが設定されているかどうかにかかわらずパスすることsetitemを使って1つのテストのためだけに朝の挨拶を"Shubho shokal"に変更し、2番目のテストでTEMPLATESが通常の状態に戻っていることを検証すること- 時刻の境界値(11:59、12:00、17:59、18:00)をテストすること
ステップ3については、2通りの方法で書いてみてください。まずgreeter.pyの内部でfrom datetime import datetimeを使い、monkeypatch.setattr(datetime, "now", ...)を試して、どのようなエラーが発生するかを正確に確認します。(名前全体であるgreeter.datetimeを独自の小さなフェイククラスで置き換える方法は機能しますが、どれほどコード量が増えるかに注目してください。)次に、greetingがnow=datetime.nowを受け取れるように変更し、時計を渡すことでテストします。
この2つのバージョンのうち、どちらを手元に残したいと思うでしょうか?多くの経験豊富なテスターが選ぶのは2番目です。両方を試してみれば、このページの説明を読むだけでなく、自分の手を動かしてその理由を実感できるはずです。
解答例
「テストを書く前に」と同じように、まずは計画から始めます。greetingの隠れた入力は、時計、GREETER_LOUD環境変数、そしてTEMPLATES辞書です。テストケースは、4つの境界の分、通常(quiet)と大文字(loud)、テンプレートの変更、そしてテンプレートが元に戻ったことの確認です。
greeter.py:
import os
from datetime import datetime
TEMPLATES = {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
}
def greeting(name, now=datetime.now):
hour = now().hour
if hour < 12:
part = "morning"
elif hour < 18:
part = "afternoon"
else:
part = "evening"
text = f"{TEMPLATES[part]}, {name}"
if os.environ.get("GREETER_LOUD") == "1":
text = text.upper()
return texttest_greeter.py:
from datetime import datetime
import pytest
import greeter
def at(hour, minute=0):
# A fake clock: a function that always returns the same moment.
return lambda: datetime(2026, 1, 5, hour, minute)
@pytest.fixture
def quiet(monkeypatch):
monkeypatch.delenv("GREETER_LOUD", raising=False)
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(11, 59, "Good morning, Rina"),
(12, 0, "Good afternoon, Rina"),
(17, 59, "Good afternoon, Rina"),
(18, 0, "Good evening, Rina"),
],
)
def test_time_boundaries(quiet, hour, minute, expected):
assert greeter.greeting("Rina", now=at(hour, minute)) == expected
def test_quiet_by_default(quiet):
assert greeter.greeting("Arif", now=at(9)) == "Good morning, Arif"
def test_loud(monkeypatch):
monkeypatch.setenv("GREETER_LOUD", "1")
assert greeter.greeting("Arif", now=at(9)) == "GOOD MORNING, ARIF"
def test_custom_morning_word(quiet, monkeypatch):
monkeypatch.setitem(greeter.TEMPLATES, "morning", "Shubho shokal")
assert greeter.greeting("Maya", now=at(8)) == "Shubho shokal, Maya"
def test_templates_are_back():
assert greeter.TEMPLATES["morning"] == "Good morning"pytest -v:
collected 8 items
test_greeter.py::test_time_boundaries[11-59-Good morning, Rina] PASSED [ 12%]
test_greeter.py::test_time_boundaries[12-0-Good afternoon, Rina] PASSED [ 25%]
test_greeter.py::test_time_boundaries[17-59-Good afternoon, Rina] PASSED [ 37%]
test_greeter.py::test_time_boundaries[18-0-Good evening, Rina] PASSED [ 50%]
test_greeter.py::test_quiet_by_default PASSED [ 62%]
test_greeter.py::test_loud PASSED [ 75%]
test_greeter.py::test_custom_morning_word PASSED [ 87%]
test_greeter.py::test_templates_are_back PASSED [100%]
============================== 8 passed in 0.01s ===============================そしてシェル側で変数がエクスポートされている状態での実行、GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sこのコードの背景にある設計判断:
- 時計はパッチではなく引数にする。 最初の試みである
monkeypatch.setattr(datetime, "now", ...)は、TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'で停止します。フェイクのdatetimeクラスを作成するよりも、greetingがnow=datetime.nowを受け取れるようにするほうが賢明です。本番コードの呼び出し側はgreeting("Rina")と書くだけで何も気にする必要がなく、テスト側はピンポイントの時刻を渡すことができます。 at(hour, minute)で時計を構築する。 小さなヘルパー関数により、各テストを1行の読みやすい形式に保ちます。greetingがnow()を呼び出すため、このヘルパーは関数を返します —datetimeオブジェクトを直接渡してしまうと、TypeError: 'datetime.datetime' object is not callableで失敗します。- 境界値をペアでテストする。 11:59と12:00、17:59と18:00のように、境界の両側1分ずつをテストします。誰かが誤って
hour <= 12と書いてしまった場合でも、12:00のケースで検出できます。 quietは前提ではなくフィクスチャにする。 期待する文字列が大文字小文字混在(通常のケース)であるすべてのテストがこのフィクスチャを要求します。GREETER_LOUDが存在しないことに依存しているためです。だからこそ、環境変数がエクスポートされた2回目の実行でも8つのテストすべてがパスするのです。test_loudは自身で変数を設定するため、このフィクスチャを必要としません。setitemの後に検証を行う。test_custom_morning_wordは1つのキーを変更します。test_templates_are_backはいかなるフィクスチャも要求せず、キーが元に戻っていることを確認します。これによって復元(undo)の動作が目に見える形で証明されます。- テストしていないこと:
str.upper()が動作することや、datetime.now()が現在時刻を返すことです。これらはどちらもPython自体の責務です。