Dependency Injection في Python: اختبر الدفع بدون Stripe حقيقي
مستوى القارئ: متوسط
لو اختبار الدفع عندك بيستنى Stripe sandbox، المقال ده هيخليك تفصل منطق الدفع عن الخدمة الخارجية وتحوّل الاختبار من ثواني غير مستقرة إلى عشرات المللي ثانية.
المشكلة باختصار
الطريقة الشائعة إنك تكتب كلاس CheckoutService جوّاه إنشاء مباشر لـ StripeClient. الكود بيشتغل، لكن الاختبار بيتحوّل من اختبار منطق إلى اختبار شبكة، credentials، rate limits، وحالة خدمة خارجية.
في سيناريو واقعي، لو عندك 120 اختبار وحدة في CI، وكل اختبار دفع يلمس API خارجي ويأخذ 1.5 إلى 2 ثانية، هتضيف دقيقتين أو أكثر لكل pull request. الأسوأ إن فشل الشبكة يظهر لك كأنه bug في الكود. ركز: المشكلة مش في Stripe. المشكلة إن الكود مربوط بتفصيلة تنفيذية من أول يوم.
المثال الأول: كود مربوط بالخدمة مباشرة
خلينا نمسك مثال دفع بسيط. المطلوب إن الطلب لا يكتمل إلا لو الدفع نجح. النسخة السريعة غالبًا تبقى بالشكل ده:
class StripeClient:
def charge(self, user_id: str, amount_cents: int) -> str:
# هنا في الحقيقة هيبقى فيه HTTP request
return "stripe_charge_123"
class CheckoutService:
def checkout(self, user_id: str, amount_cents: int) -> dict:
client = StripeClient()
charge_id = client.charge(user_id, amount_cents)
return {"status": "paid", "charge_id": charge_id}
الكود واضح، لكن الاختبار صعب. لو عايز تختبر حالة فشل الدفع، لازم تعمل monkeypatch أو تضرب sandbox أو تستخدم mock على مسار داخلي هش. أي تغيير في اسم الكلاس أو مكانه يكسر الاختبار حتى لو السلوك العام كما هو.
الحل: اعتمد على السلوك بدل الكلاس
Dependency Injection معناها ببساطة إن الكود يستقبل الاعتماد من الخارج بدل ما يصنعه داخله. التعريف الأدق: نقل مسؤولية إنشاء dependency إلى طبقة أعلى، بحيث يعتمد الكود على interface أو protocol يصف السلوك المطلوب.
مثال ممتع وبسيط: بدل ما الطباخ يبني الفرن بنفسه في كل طلب، هو يستقبل أي فرن يقدر ينفّذ وظيفة bake. في الإنتاج تستخدم فرن حقيقي. في الاختبار تستخدم فرن تدريبي لا يستهلك غاز ولا ينتظر حرارة. نفس الفكرة بالظبط في الدفع.
from typing import Protocol
class PaymentPort(Protocol):
def charge(self, user_id: str, amount_cents: int) -> str:
...
class StripePayment:
def charge(self, user_id: str, amount_cents: int) -> str:
# production HTTP call to Stripe API
return "stripe_charge_123"
class CheckoutService:
def __init__(self, payment: PaymentPort):
self.payment = payment
def checkout(self, user_id: str, amount_cents: int) -> dict:
if amount_cents <= 0:
raise ValueError("amount must be positive")
charge_id = self.payment.charge(user_id, amount_cents)
return {"status": "paid", "charge_id": charge_id}
هنا CheckoutService لا يعرف Stripe. هو يعرف فقط إن فيه dependency عندها دالة charge. ده يخلي الاختبار أسرع وأوضح.
اختبار سريع بدون شبكة
الاختبار التالي يستخدم fake بسيط. مش mock غامض، ومش API خارجي. كلاس صغير يعبّر عن السلوك المطلوب:
class FakePayment:
def __init__(self):
self.calls = []
def charge(self, user_id: str, amount_cents: int) -> str:
self.calls.append((user_id, amount_cents))
return "fake_charge_001"
def test_checkout_charges_user():
payment = FakePayment()
service = CheckoutService(payment)
result = service.checkout("user_42", 2500)
assert result == {"status": "paid", "charge_id": "fake_charge_001"}
assert payment.calls == [("user_42", 2500)]
def test_checkout_rejects_zero_amount():
payment = FakePayment()
service = CheckoutService(payment)
try:
service.checkout("user_42", 0)
except ValueError as exc:
assert str(exc) == "amount must be positive"
else:
raise AssertionError("expected ValueError")
على جهاز عادي، اختبار زي ده ممكن يخلص في 30 إلى 60ms. نفس الاختبار لو ضرب شبكة خارجية قد يأخذ 1500 إلى 2000ms، وأحيانًا يفشل بسبب timeout. الرقم هنا تقديري، لكن الفارق العملي واضح: أنت تقيس منطقك بدل ما تقيس الإنترنت.
الـ trade-off هنا
المكسب: اختبارات أسرع، كود أقل هشاشة، وقدرة أسهل على تبديل Stripe بـ PayPal أو مزود داخلي لاحقًا. بتكسب كمان إن منطقك يبقى قابل للاختبار بدون secrets وبدون sandbox.
الثمن: هتضيف طبقة تصميم زيادة. في ملف صغير أو سكربت مرة واحدة، الطبقة دي ممكن تكون overengineering. كمان الفيك الخاطئ ممكن يكذب عليك؛ لو FakePayment لا يشبه سلوك مزود الدفع الحقيقي، الاختبار هيمر بينما الإنتاج يفشل. أفضل طريقة هي توازن واضح: unit tests تستخدم fake، وintegration tests قليلة تضرب Stripe sandbox مرة أو مرتين في pipeline منفصل.
متى لا تستخدم هذه الطريقة
لا تستخدم Dependency Injection بشكل ثقيل لو عندك سكربت 50 سطر بيتشغل يدويًا مرة في الشهر. ولا تبدأ بحاوية DI ضخمة في مشروع Python صغير. الافتراض إن عندك منطق يتغير، اختبارات متكررة، أو خدمة خارجية تسبب بطء وفشل متقطع. لو الاعتماد ثابت وبسيط، مرّر function عادية بدل ما تبني abstraction كامل.
مصادر اعتمد عليها المقال
- Python docs: typing.Protocol
- pytest docs: fixtures
- Stripe docs: testing
- Martin Fowler: Inversion of Control Containers and Dependency Injection
الخطوة التالية
افتح كلاس واحد عندك بيعمل HTTP request داخله، وغيّر الكونستركتور بحيث يستقبل dependency من الخارج. بعد كده اكتب fake من 10 أسطر واختبر حالة نجاح وحالة فشل قبل ما تلمس أي mock framework.