モック(Mock) — 外部サービスがどのように呼び出されたかを確認する
決済やメールのクライアントの代役としてMockを立て、どう呼び出されたかを確認します。return_value、side_effect、autospec、patch、mocker — そしてフェイクがモックより優れるのはどんなときかを扱います。
どうやって学びますか?
解決する問題
以下は小さなチェックアウトです。カートを合計し、決済クライアントを通じてカードに請求し、顧客にメールを送ります。
payments.py:
class PaymentDeclined(Exception):
pass
class PaymentGateway:
"""Talks to the real payment provider over HTTPS."""
def __init__(self, api_key):
self.api_key = api_key
def charge(self, amount_cents, token):
raise RuntimeError("real network call — never in a test")
def refund(self, charge_id):
raise RuntimeError("real network call — never in a test")checkout.py:
from payments import PaymentDeclined
def cart_total(cart):
return sum(item["price_cents"] * item["qty"] for item in cart)
def place_order(cart, email, token, gateway, mailer):
total = cart_total(cart)
if total == 0:
raise ValueError("cart is empty")
try:
receipt = gateway.charge(total, token=token)
except PaymentDeclined:
mailer.send(email, "Your payment was declined")
return {"status": "declined"}
mailer.send(email, f"Order confirmed: {receipt['id']}")
return {"status": "paid", "charge_id": receipt["id"], "total_cents": total}実際にはchargeは本物のカードからお金を引き落とすので、テストがそこへ到達してはいけません。前の章ではmonkeypatchで何かを差し替えられましたが、ここでは差し替えるだけでは仕事の半分にすぎません。place_orderの戻り値は"paid"と告げますが、本当に問題になるバグは呼び出しの側にあります。カードには2500が請求されたのか、それとも25か?どのトークンで?1回か、2回か?メールは正しい宛先に送られたか?これらに答えるには、代役が自分がどう呼び出されたかを覚えている必要があります。
そのために、依存関係ごとに小さな記録用クラスを1つ書くこともできます。unittest.mockは、それをすでに書き終えてくれています。
この章を終える頃には
Mockを使ってクライアントの代役とし、assert_called_once_with、call_args、call_count、mock_callsで呼び出され方を確認できるようになるreturn_valueとside_effectで、モックに値を返させたり例外を送出させたりできるようになる- 素の
Mockがなぜ危険かを説明し、spec=とcreate_autospecでその穴をふさげるようになる - 名前を
patchで置き換えられるようになる。デコレータとしてもコンテキストマネージャとしても、正しい場所で - pytest-mockの
mocker.patchとmocker.spyを使えるようになる - スタブ、モック、フェイクを見分け、フェイクの方が良いテストになるのはどんなときかを判断できるようになる
前提条件: monkeypatch — 1つのテストのためだけにオブジェクトを差し替える。
テストを書く前に
Mockが登場する前に、place_orderが実際に何を約束しているかを書き出しましょう。自分で制御できる入力が1つ(カート、メール、トークン)あり、戻り値には見えない2つの副作用(請求とメール)があります。このようなコードのテストは、その両方を確認しなければなりません。
契約。 空でないカートが与えられたとき、place_orderは合計額ちょうどを、1回だけ、与えられたトークンで請求し、確認メールを送り、"paid"を返します。カードが拒否された場合は何も請求せず、お詫びのメールを送り、"declined"を返します。空のカートは、何かが外へ出る前にValueErrorを送出します。ネットワーク障害は握りつぶされません。
準備。 仮想環境にインストールされたpytest、pytestを実行する場所からpayments.pyとcheckout.pyをインポートできること、そしてmockerフィクスチャのためのpytest-mockプラグイン(pip install pytest-mock)です。unittest.mockはPythonに同梱されています。APIキーもネットワークも不要です — テストがどちらかを必要とするなら、それは単体テストではありません。
計画。
| ケース | 入力 | 期待値 | |---|---|---| | 正常系 | 2 × 1000 + 1 × 500、カード承認 | tok_visaで2500を1回請求、確認メール、"paid" | | カード拒否 | 同じカート、ゲートウェイがPaymentDeclinedを送出 | 請求の記録なし、お詫びメール、"declined" | | 空のカート | [] | ValueError("cart is empty")、ゲートウェイとメーラーは未使用 | | ネットワーク障害 | ゲートウェイがTimeoutErrorを送出 | TimeoutErrorが伝播、メールなし | | 契約 | 任意の注文 | chargeが実際のシグネチャ(amount_cents, token)で呼ばれる |
テストすべきでないもの。 決済プロバイダ自体です — それはその作者の仕事であり、そもそもあなたのテストからは到達できません。Pythonのsum。そしてplace_orderの内部の手順です。cart_totalを1回呼ぶのか、それとも計算をインラインで書くのかは、それ自身の都合です。計画に挙げるのは結果と、境界を越える唯一の呼び出しだけで、それ以外は何も挙げません。これを覚えておいてください。これが役に立つモックと有害なモックを分ける違いであり、章の最後でこの話に戻ります。
Mock — すべての呼び出しを記録するオブジェクト
from unittest.mock import Mock
gateway = Mock()
result = gateway.charge(2500, token="tok_visa")
gateway.refund("ch_1")
print(result)
print(gateway.charge.called, gateway.charge.call_count)
print(gateway.charge.call_args)
print(gateway.mock_calls)<Mock name='mock.charge()' id='132252166523936'>
True 1
call(2500, token='tok_visa')
[call.charge(2500, token='tok_visa'), call.refund('ch_1')]gatewayにはchargeメソッドがありません。何も定義されていないからです。Mockは、あなたが触れたあらゆる属性を勝手に作り出し、その属性自身もまた呼び出し可能なMockです。各呼び出しは記録されます。calledは呼び出されたかどうかを、call_countは何回呼ばれたかを、call_argsは正確にどんな引数だったかを示します。親のmock_callsは、あらゆるメソッドへのあらゆる呼び出しを順番どおりに保持します — 順序が重要になる場合に便利です。
それらの属性を素のassertで読むこともできますが、アサーションメソッドなら1行で表現でき、失敗したときには理由を説明してくれます:
from unittest.mock import Mock
gateway = Mock()
gateway.charge(2500, token="tok_visa")
gateway.charge.assert_called_once_with(2500, token="tok_visa")
print("first check passed")
gateway.charge.assert_called_once_with(2500, token="tok_amex")first check passed
Traceback (most recent call last):
...
AssertionError: expected call not found.
Expected: charge(2500, token='tok_amex')
Actual: charge(2500, token='tok_visa')assert_called_once_withは2つのことを確認します。呼び出しがちょうど1回だったこと、そしてその引数が正確にこれらであったことです。その親戚には、assert_called_once()(1回、引数は任意)、assert_called_with(...)(最後の呼び出しのみ)、assert_not_called()があります。(この章のトレースバックは、最後の行だけが重要な箇所で...に省略しています。)
なぜ「1回」なのでしょうか?カードを2回請求するのは実際のバグであり、assert_called_withだけでは2回目の呼び出しに気づけないからです。
return_valueとside_effect — モックが返すもの
デフォルトでは、呼び出しは別のMockを返します。receipt["id"]を行うコードには本物の答えが必要なので、それを与えましょう:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.return_value = {"id": "ch_1", "status": "succeeded"}
print(gateway.charge(2500, token="tok_visa"))
print(gateway.charge(999, token="tok_other")){'id': 'ch_1', 'status': 'succeeded'}
{'id': 'ch_1', 'status': 'succeeded'}引数が何であっても同じ答えです。それでは足りないときは、side_effectを設定します。これには3つの形式があります。
例外 — gateway.charge.side_effect = PaymentDeclined("card declined")は、どの呼び出しでもそれを送出します。これは、実際に起こすのが難しい不幸な経路 — 拒否されたカード、タイムアウト、サーバーからの500 — に到達する方法です。
リスト — 呼び出しごとに1つずつ、順番に。リスト内の例外は送出され、それ以外は返されます。「1回失敗してから成功する」ケースに適しています:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.side_effect = [
TimeoutError("gateway timed out"),
{"id": "ch_2", "status": "succeeded"},
]
try:
gateway.charge(2500, token="tok_visa")
except TimeoutError as exc:
print("first call:", exc)
print("second call:", gateway.charge(2500, token="tok_visa"))
gateway.charge(2500, token="tok_visa")first call: gateway timed out
second call: {'id': 'ch_2', 'status': 'succeeded'}
Traceback (most recent call last):
...
StopIteration3回目の呼び出しでは、リストが空になっていました。モックからのStopIterationは、コードが想定より多く呼び出したことを意味します — これ自体、知っておく価値があります。
関数 — モックと同じ引数で呼び出され、それが返すもの何でもが答えになります。gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"}は、入力に依存した答えを返します — そして呼び出しは依然として記録され、数えられます。
MagicMock — コードがlen、with、forを使うとき
素のMockはPythonの特殊メソッドをサポートしていません。len(Mock())はTypeError: object of type 'Mock' has no len()を送出し、with Mock():も失敗します。MagicMockはこれらを妥当なデフォルト値でサポートします — len(MagicMock())は0で、withブロック内で動作し、m.__len__.return_value = 3で答えを変えられます。
代役がコンテキストマネージャ、コンテナ、あるいはイテレータとして使われるときはMagicMockを選びましょう — たとえばwithブロック内のデータベース接続です。後述するpatchとmocker.patchは、デフォルトでMagicMockを作成します。
危険性: Mockは何にでも「はい」と言う
その従順さには代償があります。メソッド名を打ち間違えても、誰も文句を言いません:
from unittest.mock import Mock
gateway = Mock()
gateway.chrage(2500, token="tok_visa") # typo in the method name
print("no error — the typo was recorded as a new method")
print(gateway.mock_calls)no error — the typo was recorded as a new method
[call.chrage(2500, token='tok_visa')]もしそのタイポがcheckout.pyの中にあったなら、素のMockを使ったテストはパスしてしまい、最初の本物のお客様がAttributeErrorに遭遇することになります。間違った引数も同じようにすり抜けます。ここでは、間違った方法と正しい方法を並べて示します — テスト対象のコードはトークンを忘れています:
from unittest.mock import Mock, create_autospec
from payments import PaymentGateway
def charge_order(gateway, total, token):
return gateway.charge(total) # bug: the token is never passed
def test_with_a_plain_mock():
gateway = Mock()
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()
def test_with_an_autospec():
gateway = create_autospec(PaymentGateway, instance=True)
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()$ pytest -q --tb=line test_typo.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'token'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'token'
=========================== short test summary info ============================
FAILED test_typo.py::test_with_an_autospec - TypeError: missing a required ar...
1 failed, 1 passed in 0.07s素のMockは壊れた呼び出しを素通りさせました — 何も守らないグリーンのテストです。create_autospecは本物のPaymentGatewayからモックを組み立てます。そこにはそのクラスの属性しかなく、あらゆるメソッドが引数を実際のシグネチャと照合します。instance=Trueは「そのクラスのインスタンス」を意味し、クラスそのものではありません。
厳しさのレベルは3つあります:
Mock()— 任意の属性、任意の引数。Mock(spec=PaymentGateway)— 実在する属性名のみ(gateway.chrageはAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?を送出)、ただし引数はチェックされません。create_autospec(PaymentGateway, instance=True)、またはpatchでのautospec=True— 実在する名前と実際のシグネチャの両方。
これがタイポ以外になぜ重要なのでしょうか?本物のクライアントは変わるからです。ライブラリのアップグレードでメソッドの名前が変わったり、必須引数が追加されたりすると、autospecモックはその日のうちに失敗しますが、素のMockはもう動かないコードに同意し続けます。本物のクラスの代役には、最も厳しいレベルを使ってください。
Python自身も、ある種のタイポは防いでくれます。3.12以降、assert_called_once_wihtのような綴り間違いのアサーションは、黙ってパスする代わりにAttributeError: 'assert_called_once_wiht' is not a valid assertion.を送出します。ただしこの防御は、モック自身のアサーション名しか知りません。あなたのゲートウェイにchargeはあってchrageはない、ということは分かりません — それが分かるのはspecだけです。
patch — コード自身が参照する名前を置き換える
place_orderはゲートウェイを引数として受け取るので、モックを渡すのは簡単です。しかし、コードが依存関係を自分で取得することもよくあります:
# emailer.py
def send_email(to, subject):
raise RuntimeError("real SMTP call — never in a test")
# signup.py
from emailer import send_email
def register(email):
send_email(email, "Welcome aboard")
return {"email": email, "active": True}unittest.mock.patchは、テストの間だけ名前をMagicMockに置き換え、その後で元に戻します。デコレータとして(モックは引数として渡されます)、またはコンテキストマネージャとして使えます:
from unittest.mock import patch
from signup import register
@patch("signup.send_email")
def test_register_as_decorator(send_email):
user = register("[email protected]")
assert user["active"] is True
send_email.assert_called_once_with("[email protected]", "Welcome aboard")
def test_register_as_context_manager():
with patch("signup.send_email") as send_email:
register("[email protected]")
send_email.assert_called_once_with("[email protected]", "Welcome aboard")$ pytest -q test_signup.py
.. [100%]
2 passed in 0.08s対象は"signup.send_email"であり、"emailer.send_email"ではありません。これは前の章のルールです。名前が定義されている場所ではなく、参照される場所でパッチする。 from emailer import send_emailは名前をsignupにコピーしたので、registerはそこを参照します。間違った方法 — 元のモジュールをパッチする方法 — では、コピーは手つかずのまま、本物の関数が実行されます:
from unittest.mock import patch
from signup import register
def test_patched_in_the_wrong_place():
with patch("emailer.send_email") as send_email:
register("[email protected]")
send_email.assert_called_once()$ pytest -q --tb=line test_signup_wrong.py
F [100%]
=================================== FAILURES ===================================
E RuntimeError: real SMTP call — never in a test
/home/you/shop/emailer.py:2: RuntimeError: real SMTP call — never in a test
=========================== short test summary info ============================
FAILED test_signup_wrong.py::test_patched_in_the_wrong_place - RuntimeError: ...
1 failed in 0.08sエラーはemailer.pyから来ています — 本物の方です。ここでは代役が大きな音を立てて例外を送出していますが、本物のメールクライアントなら単にメールを送ってしまっていたでしょう。
patchも、以前と同じ理由でautospec=Trueを受け付けます。patch("signup.send_email", autospec=True)は、(to, subject)しか受け付けないモックを作ります。
mocker — pytest-mockのフィクスチャ
pytest-mockプラグインは、これらのすべてをmockerという名前のフィクスチャにまとめます。mocker.patchはpatchと同じ引数を取り、デコレータもwithも不要で、テストが終わると自動的に元に戻されます — monkeypatchと同じです。mocker.spyだけは異色です。これは何も置き換えません。本物のメソッドが実行され、スパイはただ見ているだけです。
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from signup import register
def test_register_with_mocker(mocker):
send_email = mocker.patch("signup.send_email", autospec=True)
register("[email protected]")
send_email.assert_called_once_with("[email protected]", "Welcome aboard")
def test_spy_watches_a_real_object(mocker):
gateway = FakeGateway()
spy = mocker.spy(gateway, "charge")
place_order([{"price_cents": 700, "qty": 1}], "[email protected]", "tok", gateway, FakeMailer())
spy.assert_called_once_with(700, token="tok")
assert spy.spy_return == {"id": "ch_1"}
assert gateway.charges == [("ch_1", 700, "tok")]$ pytest -q test_signup_mocker.py
.. [100%]
2 passed in 0.08sスパイはモックと同じように呼び出しを記録し、本物の結果をspy_returnに保持し、そして本物のchargeはちゃんとその仕事をしました — gateway.chargesが埋まっています。(FakeGatewayは次のセクションで定義します。)mockerはmocker.Mock、mocker.MagicMock、mocker.create_autospecも提供するので、テストは1つのフィクスチャからすべてを手に入れられます。
スタブ、モック、フェイク — そしてモックすべきでないとき
3つの言葉はよく混同されますが、その違いがテストで捕まえられるものを決めます:
- スタブは答えるだけです。
gateway.charge.return_value = {"id": "ch_1"}とし、呼び出され方は一切確認しません。テスト対象のコードに値を与えます。 - モックは、後から尋問するスタブです。
assert_called_once_with(...)です。テストの主眼は呼び出しそのものにあります。 - フェイクは本物の、動く、小さな実装です — 銀行とやり取りする代わりにリストを保持するインメモリのゲートウェイなどです。
モックで作ったテストは、コードがどのように仕事をしたかを確認します。やりすぎると、テストはコードが何をしたかを確認しなくなります。まずは間違った方法です:
import checkout
from checkout import place_order
def test_place_order_over_mocked(mocker):
mocker.patch("checkout.cart_total", return_value=2500)
gateway = mocker.Mock()
gateway.charge.return_value = {"id": "ch_1"}
mailer = mocker.Mock()
place_order([{"price_cents": 1, "qty": 1}], "[email protected]", "tok", gateway, mailer)
checkout.cart_total.assert_called_once()
gateway.charge.assert_called_once_with(2500, token="tok")
mailer.send.assert_called_once()$ pytest -q test_overmock.py
. [100%]
1 passed in 0.07sパスします。カートの金額は1セントなのに、テストは2500の請求を「確認」しています — テスト自身が計算を置き換えてしまったからです。cart_totalを壊しても、このテストはまだパスします。振る舞いを何も変えずにcart_totalの名前を変えると、失敗します。これが過剰モックの匂いです。実装を行単位で写し取り、自分自身のモックをテストし、リファクタリングのたびに壊れるテストです。
正しい方法は、章の冒頭の計画に従います。システムの境界で、そしてそこだけでモックする。 決済プロバイダとSMTPサーバーが境界です。cart_totalはあなた自身のコードです — そのまま実行させましょう。そして、小さなインターフェースを持つ協力オブジェクトには、たいていモックよりフェイクの方が優れています:
from payments import PaymentDeclined
class FakeGateway:
"""An in-memory stand-in for PaymentGateway: same methods, no network."""
def __init__(self, declines=False):
self.declines = declines
self.charges = []
def charge(self, amount_cents, token):
if self.declines:
raise PaymentDeclined("card declined")
charge_id = f"ch_{len(self.charges) + 1}"
self.charges.append((charge_id, amount_cents, token))
return {"id": charge_id}
def refund(self, charge_id):
self.charges = [c for c in self.charges if c[0] != charge_id]
class FakeMailer:
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject))フェイクを使ったテストは、呼び出しの並びではなく、状態(リストに最終的に何が入ったか)に対してアサートします。これは振る舞いの説明のように読め、振る舞いが保たれている限りリファクタリングに耐えます。フェイクはfakes.pyやconftest.pyのフィクスチャに一度だけ書き、すべてのテストがそれを再利用します。唯一の弱点は、フェイクが本物のクライアントからずれてしまう可能性があることです — だからこそ、完全な例では契約のためのautospecテストを1つ残しているのです。
完全な例
test_checkout.pyは、各ツールをあるべき場所に置きながら、テスト計画を行ごとに実装します — 振る舞いにはフェイク、契約にはautospecモック、失敗にはside_effectです:
from unittest.mock import create_autospec
import pytest
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from payments import PaymentGateway
CART = [{"price_cents": 1000, "qty": 2}, {"price_cents": 500, "qty": 1}]
EMAIL = "[email protected]"
# Behaviour, checked with fakes: what happened, not which calls were made.
def test_paid_order_charges_once_and_sends_a_receipt():
gateway, mailer = FakeGateway(), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "paid", "charge_id": "ch_1", "total_cents": 2500}
assert gateway.charges == [("ch_1", 2500, "tok_visa")]
assert mailer.sent == [(EMAIL, "Order confirmed: ch_1")]
def test_declined_card_sends_an_apology_and_charges_nothing():
gateway, mailer = FakeGateway(declines=True), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "declined"}
assert gateway.charges == []
assert mailer.sent == [(EMAIL, "Your payment was declined")]
def test_empty_cart_never_reaches_the_gateway():
gateway, mailer = FakeGateway(), FakeMailer()
with pytest.raises(ValueError, match="cart is empty"):
place_order([], EMAIL, "tok_visa", gateway, mailer)
assert gateway.charges == []
assert mailer.sent == []
# The contract with the real client, checked with an autospec mock.
def test_gateway_is_called_with_the_real_signature():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.return_value = {"id": "ch_9"}
place_order(CART, EMAIL, "tok_visa", gateway, FakeMailer())
gateway.charge.assert_called_once_with(2500, token="tok_visa")
gateway.refund.assert_not_called()
def test_a_timeout_propagates_and_sends_nothing():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.side_effect = TimeoutError("gateway timed out")
mailer = FakeMailer()
with pytest.raises(TimeoutError):
place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert mailer.sent == []$ pytest -v test_checkout.py
============================= test session starts ==============================
collected 5 items
test_checkout.py::test_paid_order_charges_once_and_sends_a_receipt PASSED [ 20%]
test_checkout.py::test_declined_card_sends_an_apology_and_charges_nothing PASSED [ 40%]
test_checkout.py::test_empty_cart_never_reaches_the_gateway PASSED [ 60%]
test_checkout.py::test_gateway_is_called_with_the_real_signature PASSED [ 80%]
test_checkout.py::test_a_timeout_propagates_and_sends_nothing PASSED [100%]
============================== 5 passed in 0.08s ===============================注目すべき点が2つあります。
1つ目は、3つの振る舞いテストにはassert_called...が一切含まれていないことです。これらは結果 — 戻り値、記録された請求、送信されたメール — を確認しているので、place_orderをゼロから書き直しても、依然として成り立ちます。
2つ目は、呼び出しを確認する唯一のテストがPaymentGatewayとの契約であり、create_autospecを使っていることです。誰かがchargeの名前を変えたり、必須引数を追加したりすると、そのテストは黙って同意するのではなく失敗します。計画の5行が5つのテストに対応し、place_orderの内部をテストするものは何もありません。
問題が起きたとき
AssertionError: expected call not found. モックは呼び出されましたが、この引数では呼び出されていません。メッセージはExpected:とActual:を上下に並べて表示します — 文字単位で比較してください。位置引数とキーワード引数(charge(2500, "tok")とcharge(2500, token="tok"))は、別の呼び出しとして扱われます。
AssertionError: Expected 'charge' to be called once. Called 2 times. コードがそれを2回以上呼び出しており、Calls:の行にすべての呼び出しが並びます。多くの場合、リトライループか、1つのテスト内で2つの操作にわたって同じモックを再利用したことが原因です。
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? specが仕事をしている証拠です。コードが、本物のクラスには存在しない名前を使っています。直すべきはテストではなく、コードです。
TypeError: missing a required argument: 'token' autospecモックが、実際のシグネチャに合わない呼び出しを捕まえました。本物のクライアントに対しても、コードは同じように失敗するはずです。
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' patchの対象が、存在しないものを指しています — タイポか、間違ったモジュールです。patchは新しい属性を勝手に作り出すことを拒みますが、これは良いことです。
パッチが「何もしなかった」のに、本物のサービスが呼び出された 名前が定義されている場所でパッチし、参照される場所ではパッチしていません。モジュールがfrom emailer import send_emailを行っているなら、"signup.send_email"をパッチしてください。
モック呼び出しからのStopIteration side_effectがリストで、コードがリストの要素数より多く呼び出しました。
結果に、データではなく<MagicMock name='mock.charge().__getitem__()' ...>が入っている return_valueを設定しておらず、MagicMockがreceipt["id"]に別のモックで気軽に答えてしまいました。素のMockなら、同じコードはTypeError: 'Mock' object is not subscriptableで失敗します。return_valueには、本物のクライアントが返す形を設定してください。
fixture 'mocker' not found pytestを実行している環境にpytest-mockがインストールされていません。pip install pytest-mockです。
ステップ 4 / 6 — 予想する
理解度をチェック
side_effectが3つの要素のリストで、真ん中の要素が例外です。正確には何が表示されますか?
from unittest.mock import Mock
client = Mock()
client.fetch.side_effect = [1, ValueError("boom"), 3]
print(client.fetch())
try:
client.fetch()
except ValueError as exc:
print("error:", exc)
print(client.fetch(), client.fetch.call_count)- A1 error: boom 3 2
- B1 error: boom 3 3
- C[1, ValueError('boom'), 3] error: boom 3 1
- D1 ValueError('boom') 3 3
テストはRuntimeError: real SMTP callで失敗します — 本物のsendが実行されてしまいました。どの変更なら直りますか?
# mailer.py
def send(to, subject):
raise RuntimeError("real SMTP call")
# orders.py
from mailer import send
def confirm(order_id, email):
send(email, f"Order {order_id} confirmed")
# test_orders.py
from orders import confirm
def test_confirm_sends_one_email(mocker):
send = mocker.patch("mailer.send")
confirm(7, "[email protected]")
send.assert_called_once_with("[email protected]", "Order 7 confirmed")- A`mocker.patch`の代わりに`mocker.spy(mailer, "send")`を使う
- B`send`を呼び出す前に`orders.py`に`import mailer`を追加する
- C`mocker.patch("orders.send")`と書く — `orders`が名前を参照する場所で
- D`mocker.patch("mailer.send", autospec=True)`と書く
payがトークンを忘れています。どの代役を使ったテストならこのバグを捕まえられるでしょうか?
class PaymentGateway:
def charge(self, amount_cents, token):
...
def pay(gateway, total):
return gateway.charge(total) # the token is forgotten- Acreate_autospec(PaymentGateway, instance=True)
- BMagicMock()
- CMock()
- DMock(spec=PaymentGateway)
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
weather.pyを作成し、メソッドget_forecast(self, city)がRuntimeErrorを送出するクラスWeatherClient — これは本物のHTTPクライアントの代役です — と、関数umbrella_advice(city, client)を実装してください。この関数はclient.get_forecast(city)を呼び出します。これは{"rain_chance": 70}のような辞書を返し、降水確率が50以上なら"take an umbrella"を、それ以外なら"leave it at home"を返します。クライアントがTimeoutErrorを送出した場合は、"forecast unavailable"を返します。
まずテスト計画(ケース | 入力 | 期待値)から始め、それからtest_weather.pyを書いてください:
create_autospec(WeatherClient, instance=True)とreturn_valueを使い、両方のアドバイス — ちょうど50という境界も含めて — をテストし、assert_called_once_withで都市がそのまま渡されたことを確認すること。side_effect = TimeoutError(...)を使って、"forecast unavailable"の経路をテストすること。side_effectにリストを設定して、1つのモックから2つの異なる答えを得ること。- 都市 → 降水確率の辞書を保持する
FakeWeatherClientを書き、それを使ってテストすること。
次に1つ実験です。ライブラリがアップグレードされたと想定して、本物のメソッドをget_forecast(self, city, units)に変えてください。Mock()を使うテストと、create_autospecを使うテストを実行します。気づくのはどちらでしょうか?
解答例
weather.py:
class WeatherClient:
"""Stands for the real HTTP client."""
def get_forecast(self, city):
raise RuntimeError("real HTTP call — never in a test")
def umbrella_advice(city, client):
try:
forecast = client.get_forecast(city)
except TimeoutError:
return "forecast unavailable"
if forecast["rain_chance"] >= 50:
return "take an umbrella"
return "leave it at home"計画:
| ケース | 入力 | 期待値 | |---|---|---| | 雨 | 確率70 | "take an umbrella" | | 境界 | 確率50 | "take an umbrella" | | ちょうど下 | 確率49 | "leave it at home" | | ネットワーク障害 | クライアントがTimeoutErrorを送出 | "forecast unavailable" | | 契約 | 任意の都市 | get_forecastがその都市で1回呼ばれる |
test_weather.py:
from unittest.mock import create_autospec
import pytest
from weather import WeatherClient, umbrella_advice
@pytest.fixture
def client():
return create_autospec(WeatherClient, instance=True)
@pytest.mark.parametrize(
"chance, advice",
[(70, "take an umbrella"), (50, "take an umbrella"), (49, "leave it at home")],
)
def test_advice_follows_the_rain_chance(client, chance, advice):
client.get_forecast.return_value = {"rain_chance": chance}
assert umbrella_advice("Dhaka", client) == advice
client.get_forecast.assert_called_once_with("Dhaka")
def test_timeout_gives_a_polite_answer(client):
client.get_forecast.side_effect = TimeoutError("no answer in 5s")
assert umbrella_advice("Dhaka", client) == "forecast unavailable"
def test_one_mock_two_answers(client):
client.get_forecast.side_effect = [{"rain_chance": 90}, {"rain_chance": 10}]
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Dhaka", client) == "leave it at home"
assert client.get_forecast.call_count == 2
class FakeWeatherClient:
def __init__(self, chances):
self.chances = chances
def get_forecast(self, city):
return {"rain_chance": self.chances[city]}
def test_with_a_fake():
client = FakeWeatherClient({"Dhaka": 80, "Cairo": 5})
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Cairo", client) == "leave it at home"$ pytest -v test_weather.py
============================= test session starts ==============================
collected 6 items
test_weather.py::test_advice_follows_the_rain_chance[70-take an umbrella] PASSED [ 16%]
test_weather.py::test_advice_follows_the_rain_chance[50-take an umbrella] PASSED [ 33%]
test_weather.py::test_advice_follows_the_rain_chance[49-leave it at home] PASSED [ 50%]
test_weather.py::test_timeout_gives_a_polite_answer PASSED [ 66%]
test_weather.py::test_one_mock_two_answers PASSED [ 83%]
test_weather.py::test_with_a_fake PASSED [100%]
============================== 6 passed in 0.08s ===============================実験です。WeatherClientのメソッドをget_forecast(self, city, units)に変え、このtest_drift.pyを使います:
from unittest.mock import Mock, create_autospec
from weather import WeatherClient, umbrella_advice
def test_plain_mock():
client = Mock()
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"
def test_autospec():
client = create_autospec(WeatherClient, instance=True)
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"$ pytest -q --tb=line test_drift.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'units'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'units'
=========================== short test summary info ============================
FAILED test_drift.py::test_autospec - TypeError: missing a required argument:...
1 failed, 1 passed in 0.07s各判断の理由:
- フィクスチャがautospecモックを組み立てる。 各テストが新品のモックを受け取るので、呼び出し回数がテスト間で漏れることはなく、厳しいバージョンが覚えておくべき何かではなくデフォルトになります。
parametrizeが70、50、49をカバーする。>=が最も間違えやすい行であり、50と49はその両側に位置します。「雨」のケースが1つだけでは、>でもパスしてしまいます。assert_called_once_with("Dhaka")はパラメータ化されたテストの中にある。 都市をそのまま渡すことはクライアントとの契約の一部だからです — 境界を越える唯一の呼び出しです。- タイムアウトのテストは戻り値だけを確認する。 重要なのはユーザーが見るものであり、クライアントに何回尋ねたかは約束の一部ではありません。
- フェイクはテストごとの準備が不要。 世界を記述し(
{"Dhaka": 80, "Cairo": 5})、それについて問い合わせるだけです。自分で所有するクライアントには、たいていこちらがより読みやすいテストです。 - 実験は素の
Mockの代償を示します。アップグレード後、本物のクライアントはすべての呼び出しで失敗するはずなのに、素のモックのテストはグリーンのままです。autospecのテストはその日のうちに、足りない引数の名前を挙げて失敗します。