بعد أن تكتب دالة في مشروعك، كيف تتأكد أنها ما زالت تعمل بعد تعديل الكود؟ وكيف تتأكد أن حساب الخصم، أو قراءة ملف، أو تنفيذ أمر في برنامج CLI يعطي النتيجة نفسها في الحالات الطبيعية والحالات غير المتوقعة؟ التجربة اليدوية مفيدة، لكنها تصبح بطيئة وغير مضمونة عندما يبدأ المشروع في النمو.
هنا يأتي دور unittest: مكتبة قياسية مدمجة مع بايثون تساعدك على كتابة اختبارات تلقائية للدوال والكلاسات. بدل أن تشغل البرنامج كل مرة وتدخل القيم يدويًا، تكتب اختبارات واضحة ثم تطلب من بايثون تشغيلها. إذا نجحت كلها، تحصل على ثقة أكبر في أن التعديل الأخير لم يكسر وظيفة قديمة.
هذا الدرس يكمل ما بدأته في سلسلة بايثون بعد الأساسيات: تنظيم ملفات المشروع، قراءة CSV، بناء برنامج سطر أوامر، وتسجيل أحداث البرنامج باستخدام logging. الآن سنضيف طبقة مهمة تجعل المشروع أكثر قابلية للصيانة: اختبارات آلية بسيطة وواضحة.
{alertInfo} الفكرة ببساطة: اختبار الوحدةunit testهو كود صغير يفحص جزءًا محددًا من برنامجك، مثل دالة واحدة. تكتب القيمة المتوقعة، تشغل الاختبار، ثم يخبركunittestهل النتيجة صحيحة أم لا.
{getToc} $title={محتوى المقال}
ما هو unittest في بايثون؟
unittest هو إطار اختبارات مدمج مع بايثون؛ لذلك لا تحتاج إلى تثبيته باستخدام pip. يوفر أدوات لإنشاء الاختبارات وتشغيلها ومقارنة النتيجة الفعلية بالنتيجة التي تتوقعها.
الفكرة الأساسية بسيطة:
- تكتب دالة تؤدي وظيفة محددة.
- تكتب ملف اختبار منفصل يستورد هذه الدالة.
- تحدد المدخلات والنتيجة المتوقعة.
- تشغّل الاختبارات من Terminal.
- تصلح الكود إذا أخبرك الاختبار أن هناك فشلًا.
توثيق بايثون الرسمي يصف unittest بأنه مجموعة أدوات لبناء الاختبارات وتشغيلها، ويعتمد أسلوبًا منظمًا حول TestCase ودوال التأكد المختلفة.
لماذا لا نكتفي بالتجربة اليدوية؟
لنفترض أن لديك دالة تحسب السعر بعد الخصم. عند إنشائها قد تجرب ثلاث قيم يدويًا وتجد أنها تعمل. لكن بعد أسبوع تضيف التحقق من نسبة الخصم، ثم تعدل المعادلة، ثم تنقل الكود إلى ملف آخر. هل ستتذكر تجربة كل حالة قديمة مرة أخرى؟
| التجربة اليدوية | الاختبارات التلقائية |
|---|---|
| تدخل القيم بنفسك في كل مرة. | تشغل الاختبارات كلها بأمر واحد. |
| قد تنسى حالة مهمة. | تسجل الحالات المهمة داخل ملفات الاختبار. |
| مناسبة للتجارب السريعة. | مناسبة لحماية وظائف المشروع مع الوقت. |
| لا تعطي تقريرًا منظمًا عن النجاح والفشل. | توضح اسم الاختبار الفاشل وسبب الفشل. |
الاختبارات لا تغني عن تجربة البرنامج كاملًا، لكنها تحمي الأجزاء التي تعرف كيف يجب أن تعمل.
المثال الذي سنختبره: دالة حساب السعر بعد الخصم
سنستخدم دالة بسيطة، لكنها قريبة من مشروع عملي. تنفذ الدالة ثلاث مهام:
- تتأكد أن السعر ليس سالبًا.
- تتأكد أن نسبة الخصم بين
0و100. - تعيد السعر النهائي بعد الخصم.
أنشئ ملفًا باسم calculator.py واكتب فيه:
def calculate_discount(price, discount):
if price < 0:
raise ValueError("السعر لا يمكن أن يكون سالبًا.")
if not 0 <= discount <= 100:
raise ValueError("نسبة الخصم يجب أن تكون بين 0 و100.")
discount_value = price * discount / 100
return price - discount_value
يمكنك تجربة الدالة يدويًا:
print(calculate_discount(200, 15))
والنتيجة المتوقعة:
170.0
لكننا لا نريد الاكتفاء بهذه التجربة. نريد اختبار حالات متعددة آليًا.
كيف تنظم ملفات الاختبار داخل المشروع؟
في المشاريع الصغيرة جدًا تستطيع وضع ملف الاختبار بجوار ملف الكود. لكن في المشروع المنظم، استخدم مجلدًا مستقلًا باسم tests. هذا يجعل ملفات الاختبار واضحة وسهلة الوصول.
لاحظ قاعدة تسمية مهمة: ابدأ ملف الاختبار بكلمة test_، مثل test_calculator.py. بهذه الطريقة يستطيع unittest العثور على الاختبارات تلقائيًا عند استخدام discovery.
راجع درس تنظيم ملفات مشروع بايثون بطريقة صحيحة لفهم دور المجلدات والملفات داخل أي مشروع ينمو تدريجيًا.
أنشئ أول اختبار باستخدام TestCase
الآن أنشئ الملف tests/test_calculator.py. أولًا نستورد مكتبة unittest، ثم نستورد الدالة التي نريد اختبارها:
import unittest
from calculator import calculate_discount
بعد ذلك ننشئ class يرث من unittest.TestCase. عادة تبدأ أسماء الكلاسات في الاختبارات بكلمة Test لتكون واضحة:
class TestCalculateDiscount(unittest.TestCase):
def test_calculate_discount_with_valid_values(self):
result = calculate_discount(200, 15)
self.assertEqual(result, 170.0)
هناك ثلاث نقاط يجب فهمها هنا:
- اسم method يبدأ بـ
test_حتى يعتبرهunittestاختبارًا. resultهي النتيجة الفعلية التي أعادتها الدالة.assertEqualتقارن بين النتيجة الفعلية والنتيجة المتوقعة.
إذا كانت القيمتان متساويتين ينجح الاختبار. إذا اختلفتا يفشل ويظهر لك تقرير يوضح المشكلة.
تشغيل أول اختبار
افتح Terminal داخل المجلد الرئيسي للمشروع، أي داخل discount_project، ثم نفّذ:
python -m unittest discover -s tests -p "test_*.py" -v
في بعض أنظمة macOS وLinux قد تحتاج إلى:
python3 -m unittest discover -s tests -p "test_*.py" -v
المعاني:
-m unittest: يشغل مكتبةunittestكوحدة.discover: يبحث عن ملفات الاختبار تلقائيًا.-s tests: يبدأ البحث داخل مجلدtests.-p "test_*.py": يبحث عن الملفات التي تبدأ بـtest_.-v: يعرض أسماء الاختبارات والنتائج بتفصيل أكثر.
إذا نجح الاختبار سترى مخرجات قريبة من:
test_calculate_discount_with_valid_values (test_calculator.TestCalculateDiscount) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
أضف حالات اختبار أكثر
الاختبار الحقيقي لا يكتفي بحالة واحدة. أضف الحالات التي يجب أن يحميها برنامجك، مثل عدم وجود خصم، أو خصم كامل، أو سعر عشري.
import unittest
from calculator import calculate_discount
class TestCalculateDiscount(unittest.TestCase):
def test_calculate_discount_with_valid_values(self):
result = calculate_discount(200, 15)
self.assertEqual(result, 170.0)
def test_calculate_discount_with_zero_discount(self):
result = calculate_discount(200, 0)
self.assertEqual(result, 200.0)
def test_calculate_discount_with_full_discount(self):
result = calculate_discount(200, 100)
self.assertEqual(result, 0.0)
def test_calculate_discount_with_decimal_price(self):
result = calculate_discount(99.99, 15)
self.assertAlmostEqual(result, 84.9915, places=4)
استخدمنا assertAlmostEqual مع السعر العشري لأن العمليات العشرية باستخدام float قد تنتج تمثيلًا قريبًا جدًا من القيمة المتوقعة بدل تمثيل مطابق تمامًا في بعض الحالات.
أهم دوال التأكد Assertions في unittest
يوفر unittest دوال كثيرة تبدأ غالبًا بـ assert. لا تحفظها كلها الآن؛ ابدأ بالأكثر استخدامًا:
| الدالة | متى تستخدمها؟ | مثال |
|---|---|---|
assertEqual(a, b) |
عندما تتوقع أن قيمتين متساويتان. | self.assertEqual(total, 50) |
assertNotEqual(a, b) |
عندما تتوقع أن قيمتين مختلفتان. | self.assertNotEqual(status, "failed") |
assertTrue(value) |
عندما تتوقع قيمة صحيحة. | self.assertTrue(is_valid) |
assertFalse(value) |
عندما تتوقع قيمة غير صحيحة. | self.assertFalse(is_empty) |
assertIsNone(value) |
عندما تتوقع None. |
self.assertIsNone(result) |
assertAlmostEqual(a, b) |
لمقارنة الأرقام العشرية تقريبًا. | self.assertAlmostEqual(price, 99.99) |
assertRaises(Error) |
عندما تتوقع أن الكود يرفع استثناء محددًا. | with self.assertRaises(ValueError): |
اختبار الأخطاء باستخدام assertRaises
اختبار الخطأ لا يعني أن برنامجك فشل. أحيانًا تريد أن تتأكد أن الدالة ترفض البيانات غير الصحيحة بطريقة مقصودة. في دالتنا، من الطبيعي أن يظهر ValueError عندما يكون السعر سالبًا أو الخصم خارج النطاق.
أضف الاختبارات التالية داخل TestCalculateDiscount:
def test_negative_price_raises_value_error(self):
with self.assertRaises(ValueError):
calculate_discount(-50, 10)
def test_discount_over_100_raises_value_error(self):
with self.assertRaises(ValueError):
calculate_discount(200, 150)
المعنى: ينجح الاختبار فقط إذا رفعت الدالة ValueError. أما إذا لم يظهر الخطأ، فسيعتبر unittest الاختبار فاشلًا؛ لأن الكود قبل قيمة غير صالحة دون حماية.
الفرق بين assert العادي وunittest
ربما تعرفت سابقًا على كلمة assert داخل الكود. توجد علاقة في الاسم، لكن الاستخدام مختلف:
assert العادي |
unittest |
|---|---|
| تعليمة داخل الكود للتحقق من افتراض أو شرط. | إطار منظم لإنشاء وتشغيل مجموعة اختبارات. |
مثال: assert age >= 0 |
مثال: self.assertEqual(result, 10) |
| مناسب للتحقق الداخلي أثناء التطوير. | مناسب لاختبار الدوال والكلاسات بشكل متكرر ومنظم. |
| ليس تقرير اختبارات كاملًا. | يعرض عدد الاختبارات الناجحة والفاشلة. |
راجع أساسيات بايثون 33: شرح assert واختبار صحة الشروط إذا أردت تثبيت الفرق بين التحقق داخل الكود والاختبارات الآلية.
استخدم setUp لتجهيز بيانات مشتركة
عندما تحتاج إلى نفس البيانات قبل كل اختبار، بدل تكرارها داخل كل method استخدم setUp(). تنفذ هذه الدالة قبل كل اختبار داخل class.
import unittest
from calculator import calculate_discount
class TestCalculateDiscount(unittest.TestCase):
def setUp(self):
self.price = 200
def test_discount_10_percent(self):
result = calculate_discount(self.price, 10)
self.assertEqual(result, 180.0)
def test_discount_25_percent(self):
result = calculate_discount(self.price, 25)
self.assertEqual(result, 150.0)
استخدم setUp() عندما توجد بيانات إعداد مشتركة بوضوح. أما إذا كانت كل حالة مختلفة جدًا، فاجعل البيانات داخل الاختبار نفسه حتى يبقى كل اختبار مفهومًا وحده.
ماذا تفعل عندما يفشل الاختبار؟
فشل الاختبار ليس مشكلة بحد ذاته؛ إنه إشارة مفيدة. قد يكون السبب أحد ثلاثة أمور:
- يوجد خطأ في الدالة التي تختبرها.
- النتيجة المتوقعة التي كتبتها غير صحيحة.
- تغيرت متطلبات المشروع، وتحتاج إلى تحديث الاختبار والكود معًا.
مثال: لو غيرت الدالة بالخطأ إلى:
return price + discount_value
بدل:
return price - discount_value
سيكشف اختبار assertEqual(result, 170.0) الخطأ فورًا، حتى لو لم تتذكر تشغيل سيناريو الخصم يدويًا.
{alertWarning} نصيحة مهمة: لا تعدّل القيمة المتوقعة داخل الاختبار فقط حتى يصبح أخضر. افهم أولًا: هل تغير السلوك المطلوب فعلًا، أم أن تعديلك الأخير كسر النتيجة الصحيحة؟
اختبر الدوال الصغيرة أولًا
عند بدء استخدام الاختبارات، لا تحاول اختبار البرنامج كاملًا دفعة واحدة. ابدأ بدوال صغيرة وواضحة، مثل:
- دالة حساب سعر بعد خصم.
- دالة تتحقق من صحة بريد إلكتروني أو كلمة مرور وفق شروط محددة.
- دالة تقرأ قيمة من صف CSV ثم تحولها إلى نوع مناسب.
- دالة تنسق اسم ملف أو مسار.
- دالة تحسب إجمالي قائمة أسعار.
كلما كانت الدالة لها مدخلات ومخرجات واضحة، كان اختبارها أسهل. هذا أحد أسباب فائدة تنظيم الكود إلى دوال صغيرة بدل وضع كل منطق المشروع داخل ملف طويل.
اختبارات unittest مع برنامج سطر أوامر
في برنامج CLI، لا تبدأ باختبار input() أو طباعة Terminal مباشرة. افصل منطق العمل في دوال، ثم اجعل ملف CLI يستدعيها. هكذا يمكنك اختبار المنطق بعيدًا عن واجهة الأوامر.
بدل هذا التصميم المختلط:
# تصميم يصعب اختباره
price = float(input("السعر: "))
discount = float(input("الخصم: "))
print(price - (price * discount / 100))
اكتب منطق الحساب في دالة مستقلة، ثم استدعها من برنامج CLI:
# calculator.py
def calculate_discount(price, discount):
return price - (price * discount / 100)
# main.py
from calculator import calculate_discount
price = float(input("السعر: "))
discount = float(input("الخصم: "))
print(calculate_discount(price, discount))
بهذه الطريقة تختبر calculate_discount() بسهولة، وتبقي واجهة CLI بسيطة. راجع درس بناء برنامج سطر أوامر CLI بسيط لتطوير هذا الأسلوب داخل مشروع منظم.
تشغيل الاختبارات قبل حفظ التعديلات أو رفع المشروع
اجعل تشغيل الاختبارات عادة بسيطة قبل مشاركة مشروعك أو إضافة ميزة جديدة. في المشاريع الصغيرة يكفي تنفيذ الأمر من Terminal. ومع نمو المشروع، يمكن لاحقًا تشغيل الاختبارات تلقائيًا داخل خدمات التكامل المستمر، لكن لا تحتاج إلى ذلك في هذه المرحلة.
الأمر الذي ستحتاجه غالبًا هو:
python -m unittest discover -s tests -p "test_*.py" -v
أخطاء شائعة عند استخدام unittest
1. نسيان أن يبدأ اسم الاختبار بـ test_
إذا كتبت method باسم check_discount() بدل test_discount()، فلن يعتبرها unittest اختبارًا تلقائيًا عند discovery.
2. وضع ملف الاختبار باسم غير مناسب
سمِّه مثل test_calculator.py بدل اسم غامض مثل checks.py. هذا يجعل العثور على الاختبارات وتشغيلها أسهل.
3. اختبار أكثر من فكرة في اختبار واحد
الأفضل أن يفحص كل test سلوكًا محددًا. عندما يفشل، ستعرف مباشرة ما الذي يحتاج إلى إصلاح.
4. الاعتماد على ترتيب تنفيذ الاختبارات
يجب أن يعمل كل اختبار مستقلًا. لا تفترض أن اختبارًا آخر شغّل شيئًا قبله أو غيّر قيمة يحتاجها اختبارك.
5. اختبار تفاصيل داخلية بدل النتيجة المطلوبة
ركز على ما يفترض أن تعيده الدالة أو الخطأ الذي يجب أن ترفعه، بدل ربط الاختبار بتفاصيل تنفيذ لا يراها المستخدم.
6. تجاهل الاختبارات بعد تعديل الكود
قيمة الاختبارات تظهر عندما تشغلها باستمرار، خصوصًا بعد التعديلات التي تبدو صغيرة.
تمرين عملي
أنشئ دالة باسم calculate_average(numbers) تعيد متوسط قائمة من الأرقام. ثم اكتب اختبارات تتحقق من الحالات التالية:
- قائمة تحتوي على أرقام صحيحة.
- قائمة تحتوي على أرقام عشرية.
- قائمة فارغة ترفع
ValueError.
حل مقترح: ملف average.py
def calculate_average(numbers):
if not numbers:
raise ValueError("لا يمكن حساب متوسط قائمة فارغة.")
return sum(numbers) / len(numbers)
حل مقترح: ملف tests/test_average.py
import unittest
from average import calculate_average
class TestCalculateAverage(unittest.TestCase):
def test_average_of_integers(self):
result = calculate_average([10, 20, 30])
self.assertEqual(result, 20.0)
def test_average_of_decimals(self):
result = calculate_average([1.5, 2.5])
self.assertAlmostEqual(result, 2.0)
def test_empty_list_raises_value_error(self):
with self.assertRaises(ValueError):
calculate_average([])
روابط داخلية مفيدة من بايثون العرب
- بايثون بعد الأساسيات 5: شرح logging لتسجيل أحداث البرنامج والأخطاء
- بايثون بعد الأساسيات 4: بناء برنامج سطر أوامر CLI بسيط
- بايثون بعد الأساسيات 2: تنظيم ملفات مشروع بايثون بطريقة صحيحة
- أساسيات بايثون 33: شرح assert واختبار صحة الشروط
- أساسيات بايثون 31: شرح try وexcept للمبتدئين
- كورس بايثون بعد الأساسيات
الخلاصة
unittestمكتبة قياسية مدمجة مع بايثون لإنشاء اختبارات تلقائية.- اكتب الاختبارات في ملفات تبدأ بـ
test_وضعها داخل مجلدtestsفي المشروع المنظم. - استخدم
unittest.TestCaseلإنشاء مجموعة اختبارات مرتبطة بوظيفة أو class محدد. - ابدأ بـ
assertEqualوassertAlmostEqualوassertRaises. - استخدم
assertRaisesللتأكد من أن الدالة ترفض القيم غير الصحيحة بالطريقة المتوقعة. - شغّل الاختبارات عبر
python -m unittest discover -s tests -p "test_*.py" -v. - افصل منطق البرنامج في دوال صغيرة حتى تصبح الاختبارات أسهل وأوضح.
{alertSuccess} قاعدة ذهبية: كلما أضفت دالة مهمة إلى مشروعك، فكّر في ثلاث حالات على الأقل لاختبارها: حالة عادية، وحالة حدّية، وحالة بيانات غير صحيحة يجب أن تتعامل معها الدالة بوضوح.
مصادر خارجية رسمية للتوسع
أسئلة شائعة
هل أحتاج إلى تثبيت unittest؟
لا. مكتبة unittest تأتي ضمن المكتبة القياسية في بايثون، لذلك تستطيع استيرادها واستخدامها مباشرة.
ما الفرق بين unittest وpytest؟
unittest مدمج مع بايثون ويكفي لتعلم أساسيات الاختبارات وتنظيمها. pytest أداة خارجية شائعة توفر أسلوبًا مختصرًا وميزات إضافية، لكن البدء بـ unittest مناسب لفهم المفاهيم الأساسية دون تثبيت أدوات جديدة.
كيف أسمي ملف الاختبار؟
استخدم اسمًا يبدأ بـ test_ مثل test_calculator.py. هذا يسهل على unittest discover العثور عليه تلقائيًا.
هل أختبر كل سطر من الكود؟
لا تبدأ بهذا الهدف. اختبر السلوك المهم: المدخلات المتوقعة، الحالات الحدّية، والبيانات غير الصحيحة التي يجب أن تتعامل معها الدالة أو ترفضها.
متى أستخدم assertRaises؟
استخدمها عندما يكون رفع استثناء معين سلوكًا صحيحًا ومقصودًا، مثل محاولة حساب خصم بنسبة أكبر من 100 أو حساب متوسط قائمة فارغة.



