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}