Testing في Python — Python المتقدم
الاختبار في Python
كيف تعرف أن الكود يعمل؟ تشغيله يدوياً وتجربة بعض القيم لا يكفي: عندما تعدّل شيئاً بعد شهر، لن تعيد تجربة كل الحالات. الاختبارات الآلية كود صغير يتحقق من أن الكود يعطي النتائج المتوقعة، وتشغّلها بأمر واحد بعد كل تعديل.
اختبار الوحدة (Unit Test)
يختبر جزءاً صغيراً (دالة أو صنفاً) بمعزل عن البقية:
import unittest
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("نسبة غير صالحة")
return round(price * (1 - percent / 100), 2)
class TestDiscount(unittest.TestCase):
def test_normal(self):
self.assertEqual(apply_discount(100, 20), 80)
def test_invalid_percent(self):
with self.assertRaises(ValueError):
apply_discount(100, 150)التشغيل: python -m unittest يبحث عن الملفات التي تبدأ بـ test وينفذ كل دالة تبدأ بـ test_.
ماذا تختبر؟
- الحالة العادية: مدخلات نموذجية.
- الحالات الحدية: صفر، قائمة فارغة، أكبر/أصغر قيمة مسموحة (هنا 0 و 100).
- المدخلات الخاطئة: هل يُرمى الخطأ المناسب؟
- أخطاء سابقة: عند إصلاح bug اكتب اختباراً يمنع عودته.
pytest: الأداة الأكثر استخداماً
مكتبة خارجية (pip install pytest) تجعل الاختبارات دوالاً عادية مع assert:
def test_normal():
assert apply_discount(100, 20) == 80ثم pytest -v. ميزاتها الأهم:
- @pytest.mark.parametrize: نفس الاختبار بعدة مجموعات مدخلات.
- fixtures: إعداد بيانات مشتركة بين الاختبارات.
- pytest.raises(ValueError) لاختبار الأخطاء.
- رسائل فشل تعرض القيم الفعلية والمتوقعة.
Mock: عزل ما لا تريد تشغيله
إذا كانت الدالة ترسل بريداً أو تتصل بـ API، لا تريد ذلك في الاختبار. unittest.mock يستبدل الجزء الخارجي بكائن وهمي تتحكم في نتيجته، وتتحقق من أنه استُدعي بالمعاملات الصحيحة.
كود قابل للاختبار
أصعب جزء في الاختبار هو كود صُمم بلا اختبار: دوال تقرأ input() وتطبع وتحسب معاً، أو تستدعي date.today() في داخلها. افصل الحساب عن الإدخال والإخراج، ومرّر الاعتماديات كمعاملات (رأيت ذلك في مشروع المكتبة).
الاختبار أولاً (TDD)
أسلوب يكتب فيه المبرمج الاختبار قبل الكود: يفشل الاختبار، ثم تكتب أبسط كود يجعله ينجح، ثم تحسّن الكود والاختبار يحميك. جرّبه في التمرين.
تمرين
اكتب اختبارات (unittest أو pytest) لدالة is_strong_password(p): طولها 8 على الأقل وفيها رقم وحرف كبير. غطِّ: كلمة قوية، قصيرة، بلا رقم، بلا حرف كبير، ونصاً فارغاً.
import unittest
from unittest.mock import Mock
def apply_discount(price: float, percent: float) -> float:
if not 0 <= percent <= 100:
raise ValueError("نسبة غير صالحة")
return round(price * (1 - percent / 100), 2)
def notify_if_expensive(price: float, sender) -> bool:
"""ترسل إشعاراً عبر sender (يُمرَّر من الخارج ليسهل اختباره)."""
if price > 1000:
sender.send(f"سعر مرتفع: {price}")
return True
return False
class TestDiscount(unittest.TestCase):
def test_normal(self):
self.assertEqual(apply_discount(100, 20), 80)
def test_edges(self):
self.assertEqual(apply_discount(100, 0), 100)
self.assertEqual(apply_discount(100, 100), 0)
def test_many_cases(self):
for price, percent, expected in [(50, 10, 45), (19.99, 50, 10.0), (0, 30, 0)]:
with self.subTest(price=price, percent=percent):
self.assertEqual(apply_discount(price, percent), expected)
def test_invalid_percent(self):
with self.assertRaises(ValueError):
apply_discount(100, 150)
class TestNotify(unittest.TestCase):
def test_sends_for_expensive(self):
sender = Mock()
self.assertTrue(notify_if_expensive(1500, sender))
sender.send.assert_called_once_with("سعر مرتفع: 1500")
def test_silent_for_cheap(self):
sender = Mock()
self.assertFalse(notify_if_expensive(20, sender))
sender.send.assert_not_called()
if __name__ == "__main__":
# exit=False حتى لا يُغلق البرنامج بعد الاختبارات (مفيد داخل المحرر)
unittest.main(argv=["tests"], exit=False, verbosity=2)
اختبر فهمك