الفصل 11

المحاكاة (Mocking) — التحقق من طريقة استدعاء خدمة خارجية

ضع Mock بديلاً عن عميل دفع أو بريد وتحقق من طريقة استدعائه. return_value و side_effect و autospec و patch و mocker — ومتى يتفوق الـ fake على الـ mock.

50 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

إليك نظام دفع صغيراً. يحسب مجموع سلة المشتريات، ويخصم المبلغ من البطاقة عبر عميل دفع (payment client)، ثم يرسل بريداً إلكترونياً إلى العميل.

payments.py:

python
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:

python
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 — كائن يدوّن كل استدعاء

python
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)
text
<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 عادي تعمل، لكن دوال التحقق تقول الشيء نفسه في سطر واحد، وتشرح نفسها عند الفشل:

python
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")
text
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"] يحتاج إلى إجابة حقيقية، فأعطه إياها:

python
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"))
text
{'id': 'ch_1', 'status': 'succeeded'}
{'id': 'ch_1', 'status': 'succeeded'}

الإجابة نفسها مهما كانت الوسائط. وحين لا يكفي ذلك، اضبط side_effect. ولها ثلاثة أشكال.

استثناء — gateway.charge.side_effect = PaymentDeclined("card declined") تجعل كل استدعاء يرفعه. هكذا تصل إلى المسارات غير السعيدة التي يصعب إحداثها في الواقع: بطاقة مرفوضة، أو مهلة منتهية، أو خطأ 500 من الخادم.

قائمة — عنصر لكل استدعاء، بالترتيب. الاستثناء في القائمة يُرفع؛ وأي شيء آخر يُعاد. مناسبة لحالة "يفشل مرة ثم ينجح":

python
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")
text
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 يقول نعم لكل شيء

لهذه الطبيعة المطيعة ثمن. أخطئ في كتابة اسم دالة، ولن يعترض أحد:

python
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)
text
no error — the typo was recorded as a new method
[call.chrage(2500, token='tok_visa')]

لو كان هذا الخطأ الإملائي في checkout.py، لأمكن أن ينجح اختبار يستخدم Mock مجرداً، ولتلقى أول عميل حقيقي AttributeError. والوسائط الخاطئة تمر بالطريقة نفسها. إليك الطريقة الخاطئة والطريقة الصحيحة جنباً إلى جنب — الكود المُختبَر ينسى تمرير الرمز:

python
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()
text
$ 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 بوابتها كوسيط، لذا فتمرير كائن وهمي سهل. لكن الكود كثيراً ما يجلب اعتماديته بنفسه:

python
# 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 طوال مدة الاختبار، ثم يُعيد الأصل إلى مكانه. يعمل كمُزخرِف — فيصل الكائن الوهمي كوسيط — أو كمدير سياق:

python
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")
text
$ pytest -q test_signup.py
..                                                                       [100%]
2 passed in 0.08s

الهدف هو "signup.send_email"، لا "emailer.send_email". هذه قاعدة الفصل السابق: استبدل الاسم حيث يُبحث عنه، لا حيث عُرِّف. فقد نسخت from emailer import send_email الاسم إلى signup، لذا تبحث register هناك. الطريقة الخاطئة — استبدال الوحدة الأصلية — تترك النسخة كما هي، فتعمل الدالة الحقيقية:

python
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()
text
$ 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 فهو الاستثناء: إنه لا يستبدل شيئاً. الدالة الحقيقية تعمل؛ والجاسوس يراقب فقط.

python
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")]
text
$ 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 تطبيق حقيقي صغير يعمل فعلاً — بوابة في الذاكرة تحتفظ بقائمة بدلاً من مخاطبة بنك.

الاختبار المبني من كائنات وهمية يتحقق من كيف أدى الكود عمله. بالغ في ذلك فيتوقف الاختبار عن التحقق مما فعله. الطريقة الخاطئة أولاً:

python
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()
text
$ pytest -q test_overmock.py
.                                                                        [100%]
1 passed in 0.07s

إنه ينجح. السلة تكلّف سنتاً واحداً، والاختبار "يؤكد" خصم 2500 — لأن الاختبار نفسه استبدل الحساب. اكسر cart_total وسيظل هذا الاختبار ناجحاً. غيّر اسم cart_total دون تغيير أي سلوك وسيفشل. هذه هي رائحة الإفراط في المحاكاة (over-mocking): اختبار يحاكي التطبيق سطراً بسطر، ويختبر كائناته الوهمية، وينكسر مع كل إعادة هيكلة.

الطريقة الصحيحة تتبع الخطة من بداية الفصل: حاكِ عند حافة نظامك، وهناك فقط. مزوّد الدفع وخادم SMTP هما الحافة. أما cart_total فهو كودك — دعه يعمل. وبالنسبة للمتعاونين ذوي الواجهة الصغيرة، يتفوق الـ fake عادةً على الـ mock:

python
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 للفشل:

python
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 == []
text
$ 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.