المحاكاة (Mocking) — التحقق من طريقة استدعاء خدمة خارجية
ضع Mock بديلاً عن عميل دفع أو بريد وتحقق من طريقة استدعائه. return_value و side_effect و autospec و patch و mocker — ومتى يتفوق الـ fake على الـ mock.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك نظام دفع صغيراً. يحسب مجموع سلة المشتريات، ويخصم المبلغ من البطاقة عبر عميل دفع (payment client)، ثم يرسل بريداً إلكترونياً إلى العميل.
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؟ وبأي رمز (token)؟ مرة واحدة أم مرتين؟ وهل ذهب البريد إلى العنوان الصحيح؟ للإجابة عن ذلك، يجب أن يتذكر البديلُ كيف استُدعي.
كان بوسعك كتابة صنف صغير يسجّل الاستدعاءات، صنف لكل اعتمادية. لكن unittest.mock قد كتبه لك مسبقاً.
بنهاية هذا الفصل ستكون قادراً على
- استخدام
Mockبديلاً عن عميل ما، والتحقق من طريقة استدعائه عبرassert_called_once_withوcall_argsوcall_countوmock_calls - جعل الكائن الوهمي يُعيد قيماً أو يرفع استثناءات عبر
return_valueوside_effect - شرح سبب خطورة
Mockالمجرد، وسد هذه الثغرة عبرspec=وcreate_autospec - استبدال اسم ما عبر
patch، كمُزخرِف (decorator) وكمدير سياق (context manager)، في المكان الصحيح - استخدام
mocker.patchوmocker.spyمن إضافة pytest-mock - التمييز بين stub و mock و fake، ومعرفة متى يكون الاختبار بـ fake أفضل
المتطلبات المسبقة: monkeypatch — استبدال الأشياء لاختبار واحد.
قبل أن تكتب الاختبار
قبل أن يظهر أي Mock، اكتب ما تَعِد به place_order فعلاً. لها مُدخل واحد تتحكم فيه (السلة والبريد والرمز)، وأثران جانبيان (side effects) لا يظهران في قيمتها المُعادة — عملية خصم ورسالة بريد. اختبار كود كهذا يجب أن يتحقق من النصفين معاً.
العقد. إذا لم تكن السلة فارغة، تخصم place_order المجموع بالضبط، مرة واحدة، بالرمز المُعطى؛ وترسل بريد تأكيد؛ وتُعيد "paid". إذا رُفضت البطاقة، لا تخصم شيئاً، وترسل بريد اعتذار، وتُعيد "declined". السلة الفارغة ترفع ValueError قبل أن يغادر أي شيء النظام. وفشل الشبكة لا يُبتلع بصمت.
التجهيز. pytest داخل بيئتك الافتراضية، وإمكانية استيراد payments.py و checkout.py من المكان الذي تشغّل منه pytest، وإضافة pytest-mock من أجل الـ fixture المسمى mocker (pip install pytest-mock). أما unittest.mock فيأتي مع بايثون. لا مفتاح API ولا شبكة — إذا احتاج اختبار إلى أي منهما فهو ليس اختبار وحدة (unit test).
الخطة.
| الحالة | المدخلات | المتوقع | |---|---|---| | المسار السليم | 2 × 1000 + 1 × 500، البطاقة مقبولة | خصم واحد بقيمة 2500 بالرمز tok_visa؛ بريد تأكيد؛ "paid" | | بطاقة مرفوضة | السلة نفسها، والبوابة ترفع PaymentDeclined | لا خصم مسجَّل؛ بريد اعتذار؛ "declined" | | سلة فارغة | [] | ValueError("cart is empty")؛ البوابة والمُرسِل لم يُمسّا | | فشل الشبكة | البوابة ترفع TimeoutError | TimeoutError ينتشر للخارج؛ لا بريد | | العقد | أي طلب | استدعاء charge بالتوقيع الحقيقي (amount_cents, token) |
ما الذي لا تختبره. مزوّد الدفع نفسه — فتلك مهمة مؤلفيه، واختبارك لا يستطيع الوصول إليه أصلاً. ودالة sum في بايثون. والخطوات الداخلية لـ place_order: هل تستدعي cart_total مرة واحدة أم تُجري الحساب بنفسها، فذلك شأنها الخاص. الخطة تضم النتائج والاستدعاء الوحيد الذي يعبر الحدود — ولا شيء غير ذلك. تذكّر هذا؛ ففيه الفرق بين كائن وهمي مفيد وآخر ضار، وسنعود إليه في نهاية الفصل.
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 عادي تعمل، لكن دوال التحقق تقول الشيء نفسه في سطر واحد، وتشرح نفسها عند الفشل:
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 من أمرين: حدث استدعاء واحد بالضبط، وكان بهذه الوسائط بالضبط. وأقاربها هي assert_called_once() (مرة واحدة، بأي وسائط)، و assert_called_with(...) (الاستدعاء الأخير فقط)، و assert_not_called(). (في هذا الفصل اختُصرت رسائل التتبع إلى ... حيث لا يهم إلا السطر الأخير.)
لماذا "مرة واحدة"؟ لأن الخصم من البطاقة مرتين خطأ حقيقي، و assert_called_with وحدها لن تلاحظ الاستدعاء الثاني.
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. ولها ثلاثة أشكال.
استثناء — gateway.charge.side_effect = PaymentDeclined("card declined") تجعل كل استدعاء يرفعه. هكذا تصل إلى المسارات غير السعيدة التي يصعب إحداثها في الواقع: بطاقة مرفوضة، أو مهلة منتهية، أو خطأ 500 من الخادم.
قائمة — عنصر لكل استدعاء، بالترتيب. الاستثناء في القائمة يُرفع؛ وأي شيء آخر يُعاد. مناسبة لحالة "يفشل مرة ثم ينجح":
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):
...
StopIterationوجد الاستدعاء الثالث القائمة فارغة. ظهور 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 العادي دوال بايثون الخاصة: 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 تعني "نسخة من الصنف"، لا الصنف نفسه.
ثلاثة مستويات من الصرامة:
Mock()— أي خاصية، وأي وسائط.Mock(spec=PaymentGateway)— أسماء الخصائص الحقيقية فقط (gateway.chrageيرفعAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?)، لكن الوسائط لا تُفحص.create_autospec(PaymentGateway, instance=True)، أوautospec=Trueمعpatch— الأسماء الحقيقية و التواقيع الحقيقية.
لماذا يهم هذا أبعد من الأخطاء الإملائية؟ لأن العميل الحقيقي يتغير. حين تُعيد ترقيةُ مكتبةٍ تسميةَ دالة أو تضيف وسيطاً إلزامياً، يفشل الكائن الوهمي المبني بـ autospec في اليوم نفسه؛ بينما يظل Mock المجرد موافقاً لكود لم يعد يعمل. لأي شيء يقف بديلاً عن صنف حقيقي، استخدم أشد المستويات صرامة.
بايثون نفسها تحرس نوعاً واحداً من الأخطاء الإملائية: منذ الإصدار 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("asha@example.com")
assert user["active"] is True
send_email.assert_called_once_with("asha@example.com", "Welcome aboard")
def test_register_as_context_manager():
with patch("signup.send_email") as send_email:
register("ravi@example.com")
send_email.assert_called_once_with("ravi@example.com", "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("asha@example.com")
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 — الـ fixture الخاص بـ pytest-mock
تغلّف إضافة pytest-mock كل هذا في fixture اسمه 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("asha@example.com")
send_email.assert_called_once_with("asha@example.com", "Welcome aboard")
def test_spy_watches_a_real_object(mocker):
gateway = FakeGateway()
spy = mocker.spy(gateway, "charge")
place_order([{"price_cents": 700, "qty": 1}], "a@example.com", "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، فيحصل الاختبار على كل شيء من fixture واحد.
Stub و mock و fake — ومتى لا تستخدم المحاكاة
ثلاث كلمات تختلط، والفرق بينها يحدد ما يستطيع اختبارك اكتشافه:
- stub يجيب فقط.
gateway.charge.return_value = {"id": "ch_1"}، ولا تتحقق أبداً من طريقة استدعائه. إنه يغذّي الكود المُختبَر. - mock هو stub تستجوبه بعد ذلك:
assert_called_once_with(...). الاختبار يدور حول الاستدعاء نفسه. - fake تطبيق حقيقي صغير يعمل فعلاً — بوابة في الذاكرة تحتفظ بقائمة بدلاً من مخاطبة بنك.
الاختبار المبني من كائنات وهمية يتحقق من كيف أدى الكود عمله. بالغ في ذلك فيتوقف الاختبار عن التحقق مما فعله. الطريقة الخاطئة أولاً:
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}], "a@example.com", "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إنه ينجح. السلة تكلّف سنتاً واحداً، والاختبار "يؤكد" خصم 2500 — لأن الاختبار نفسه استبدل الحساب. اكسر cart_total وسيظل هذا الاختبار ناجحاً. غيّر اسم cart_total دون تغيير أي سلوك وسيفشل. هذه هي رائحة الإفراط في المحاكاة (over-mocking): اختبار يحاكي التطبيق سطراً بسطر، ويختبر كائناته الوهمية، وينكسر مع كل إعادة هيكلة.
الطريقة الصحيحة تتبع الخطة من بداية الفصل: حاكِ عند حافة نظامك، وهناك فقط. مزوّد الدفع وخادم SMTP هما الحافة. أما cart_total فهو كودك — دعه يعمل. وبالنسبة للمتعاونين ذوي الواجهة الصغيرة، يتفوق الـ fake عادةً على الـ mock:
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 يتحقق من الحالة — ما انتهى إليه محتوى القائمة — لا من تسلسل الاستدعاءات. يُقرأ كوصف للسلوك، ويصمد أمام إعادة الهيكلة ما دام السلوك ثابتاً. تكتب الـ fake مرة واحدة، في fakes.py أو في fixture داخل conftest.py، وتعيد كل الاختبارات استخدامه. ضعفه الوحيد أنه قد ينحرف عن العميل الحقيقي — ولهذا يحتفظ المثال المتكامل باختبار autospec واحد للعقد.
مثال تطبيقي متكامل
ينفّذ test_checkout.py خطة الاختبار صفاً صفاً، وكل أداة في مكانها — fakes للسلوك، وكائن وهمي بـ 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 = "asha@example.com"
# 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 ===============================أمران يستحقان الانتباه.
أولاً، اختبارات السلوك الثلاثة لا تحتوي على أي assert_called... إطلاقاً. إنها تتحقق من النتيجة — القيمة المُعادة، والخصومات المسجّلة، والرسائل المرسلة — لذا يمكن إعادة كتابة place_order من الصفر وستبقى صامدة.
ثانياً، الاختبار الوحيد الذي يتحقق من استدعاء هو العقد مع PaymentGateway، ويستخدم create_autospec. إذا غيّر أحدهم اسم charge أو أضاف وسيطاً إلزامياً، يفشل هذا الاختبار بدلاً من أن يوافق بصمت. خمسة صفوف في الخطة، وخمسة اختبارات، ولا شيء يختبر ما بداخل 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. استدعاه الكود أكثر من مرة، وسطر Calls: يسرد كل استدعاء. السبب غالباً حلقة إعادة محاولة، أو كائن وهمي واحد أُعيد استخدامه لعمليتين في الاختبار.
خطأ 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-mock غير مثبّت في البيئة التي يعمل منها pytest. نفّذ pip install pytest-mock.
Step 4 of 6 — Predict
Check your understanding
قيمة side_effect قائمة من ثلاثة عناصر، أوسطها استثناء. ما الذي يُطبع بالضبط؟
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 ValueError('boom') 3 3
- C1 error: boom 3 3
- D[1, ValueError('boom'), 3] error: boom 3 1
يفشل الاختبار بالخطأ 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, "asha@example.com")
send.assert_called_once_with("asha@example.com", "Order 7 confirmed")- Aاكتب `mocker.patch("mailer.send", autospec=True)`
- Bاكتب `mocker.patch("orders.send")` — حيث تبحث `orders` عن الاسم
- Cاستخدم `mocker.spy(mailer, "send")` بدلاً من `mocker.patch`
- Dأضف `import mailer` إلى `orders.py` قبل استدعاء `send`
تنسى pay تمرير الرمز. أي بديل سيلتقط الاختبارُ المستخدمُ له هذا الخطأ؟
class PaymentGateway:
def charge(self, amount_cents, token):
...
def pay(gateway, total):
return gateway.charge(total) # the token is forgotten- AMock()
- BMagicMock()
- CMock(spec=PaymentGateway)
- Dcreate_autospec(PaymentGateway, instance=True)
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
اكتب weather.py يحتوي على صنف WeatherClient دالته get_forecast(self, city) ترفع RuntimeError — فهو يمثّل عميل HTTP الحقيقي — وعلى دالة umbrella_advice(city, client). تستدعي الدالة client.get_forecast(city) التي تُعيد قاموساً مثل {"rain_chance": 70}، وتُعيد "take an umbrella" إذا كانت النسبة 50 أو أكثر، وإلا "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على قائمة، احصل على إجابتين مختلفتين من كائن وهمي واحد. - اكتب
FakeWeatherClientيحتفظ بقاموس مدينة ← نسبة المطر، واختبر به.
ثم تجربة واحدة: افترض أن المكتبة رُقّيت، وغيّر الدالة الحقيقية إلى 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 مرة واحدة بتلك المدينة |
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 ===============================التجربة، مع get_forecast(self, city, units) في WeatherClient وملف 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سبب كل قرار:
- fixture يبني الكائن الوهمي بـ autospec. يحصل كل اختبار على نسخة جديدة، فلا تتسرب أعداد الاستدعاءات من اختبار إلى آخر، وتصبح النسخة الصارمة هي الافتراضية لا شيئاً يجب تذكّره.
parametrizeيغطي 70 و 50 و 49. سطر>=هو الأرجح أن يكون خاطئاً؛ و 50 و 49 تقعان على جانبيه. حالة "ممطرة" واحدة كانت ستنجح مع>أيضاً.assert_called_once_with("Dhaka")موضوعة في الاختبار ذي المعاملات، لأن تمرير المدينة جزء من العقد مع العميل — الاستدعاء الوحيد الذي يعبر الحدود.- اختبار المهلة يتحقق من القيمة المُعادة فقط. المهم هو ما يراه المستخدم؛ أما عدد مرات سؤال العميل فليس جزءاً من الوعد.
- الـ fake لا يحتاج إلى تجهيز في كل اختبار: تصف العالم (
{"Dhaka": 80, "Cairo": 5}) وتطرح عليه الأسئلة. لعميل تملكه، يكون هذا عادةً الاختبار الأسهل قراءة. - التجربة تُظهر ثمن
Mockالمجرد: بعد الترقية كان العميل الحقيقي سيفشل في كل استدعاء، بينما يبقى اختبار الـ Mock العادي أخضر. أما اختبار autospec فيفشل في اليوم نفسه، مسمّياً الوسيط المفقود.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz