اختبار تطبيقات Python باستخدام Pytest و Unit Testing

اختبار تطبيقات Python باستخدام Pytest و Unit Testing

اختبار البرمجيات ليس مجرد خطوة إضافية في دورة تطوير تطبيقات Python، بل هو جزء من روح المشروع نفسه. عندما نكتب اختبارات جيدة، فإننا لا نحمي الكود فقط من الأخطاء، بل نحمي وقتنا، وثقة الفريق، وراحة البال أثناء التعديل والتوسّع. كثير من المطورين يبدأون كتابة التطبيق بحماس، ثم يكتشفون بعد أسابيع أن أي تغيير صغير في جزء منه يسبب سلسلة من الأعطال غير المتوقعة. هنا يأتي الاختبار ليقول لك بهدوء: يمكنك أن تغيّر، لكنك لن تتوه، لأن لديك شبكة أمان واضحة.

في عالم Python، يبرز اسمان بقوة عندما نتحدث عن الاختبار: unittest وpytest. الأول يأتي مع Python بشكل افتراضي، وهو رسمي ومباشر ومهم جدًا لفهم الأساسيات. أما الثاني، فهو أكثر مرونة وأناقة وشيوعًا في المشاريع الحديثة، حتى أصبح عند كثير من الفرق الخيار الأول عند بناء منظومة الاختبارات. وبين الاثنين توجد علاقة جميلة: يمكنك أن تبدأ بفهم unittest لتتعلم المفاهيم الأساسية، ثم تنتقل إلى pytest لتستمتع بالبساطة والقدرة العالية على التنظيم والتوسّع.

في هذا المقال سنبني الصورة من الصفر وبهدوء. سنفهم ما هو الاختبار الوحدوي أصلًا، ولماذا يحتاجه المشروع، وكيف نكتب اختبارات قوية وقابلة للصيانة، ثم نتدرج إلى pytest وfixtures وmocking وparametrization وtest structure، ونلمس أيضًا بعض الأخطاء الشائعة التي يقع فيها المطورون عندما يبدأون الاختبار لأول مرة. سأحاول أن أكتب هذا الدليل بطريقة قريبة من المطور الحقيقي: ليس فقط “كيف تكتب الاختبار”، بل “كيف تفكر كمهندس يريد أن يبني تطبيقًا لا ينهار عند أول تعديل”.


ما هو Unit Testing ولماذا هو مهم؟

الـ Unit Testing أو الاختبار الوحدوي هو ببساطة اختبار أصغر جزء منطقي في البرنامج بشكل مستقل عن بقية الأجزاء. هذا الجزء قد يكون دالة، أو method داخل class، أو وحدة وظيفية صغيرة مثل حساب السعر النهائي، أو التحقق من صحة بريد إلكتروني، أو تحويل تاريخ إلى تنسيق معين، أو فلترة بيانات قبل إرسالها إلى قاعدة البيانات. الهدف ليس اختبار كل التطبيق مرة واحدة، بل عزل كل وحدة عن غيرها والتأكد من أنها تعمل كما يجب.

فكرة العزل مهمة جدًا. عندما تختبر دالة واحدة فقط، فأنت تعرف أن النتيجة التي حصلت عليها جاءت من تلك الدالة، لا من أثر جانبي في قاعدة البيانات أو من API خارجي أو من حالة عشوائية داخل الذاكرة. هذا يجعل الاختبار أسرع، أوضح، وأسهل في الفهم. كما أن اختبارات الوحدة عادة تكون أول خط دفاع ضد الانحدارات البرمجية، أي الأخطاء التي تعود بعد أن كنت قد أصلحتها من قبل أو بعد أن تغيّر شيء في مكان غير متوقع من التطبيق.

الفرق بين المطور الذي يكتب كودًا “يعمل الآن” والمطور الذي يكتب كودًا “سيستمر في العمل بعد شهرين” هو أن الثاني يفكر في الاختبار من البداية. ليس لأن الكود غير الجيد سيء بطبيعته فقط، بل لأن الحياة البرمجية مليئة بالتغييرات: إضافة خاصية جديدة، تعديل رسالة، تغيير قاعدة بيانات، استبدال مكتبة، تحديث API، أو حتى إصلاح bug صغير يؤدي إلى كسر شيء آخر. الاختبارات تجعل هذه المخاطر أكثر وضوحًا وأقل رعبًا.

ولعل أجمل ما في Unit Testing أنه لا يفيد فقط في اكتشاف الأخطاء، بل يفيد أيضًا في تصميم الكود نفسه. عندما تحاول كتابة اختبار لدالة صعبة الاختبار، فغالبًا هذا يعني أن تصميم الدالة نفسها يحتاج إلى تحسين: ربما الدالة كبيرة جدًا، أو تعتمد على أشياء خارجية أكثر من اللازم، أو تمتلئ بالمسؤوليات المتداخلة. بهذا المعنى، الاختبار ليس مجرد أداة تحقق، بل أداة لتعلّم بناء كود أنظف.


الفرق بين unittest و pytest

عند أول احتكاك مع الاختبار في Python، غالبًا ستسمع باسم unittest. هذا الإطار جزء من المكتبة القياسية في Python، أي أنه لا يحتاج إلى تثبيت خارجي. يوفّر بنية تقليدية مبنية على classes وassertions وأساليب واضحة مثل setUp وtearDown. إذا كنت قادمًا من خلفية Java أو C# أو أي بيئة اعتادت على الأنماط الكلاسيكية للاختبار، فقد تشعر أن unittest مألوف جدًا.

أما pytest فهو إطار حديث أكثر بساطة ومرونة. من الأشياء الجميلة فيه أنه لا يفرض عليك أسلوبًا معقدًا؛ يمكنك كتابة اختبار كدالة عادية، وتستخدم assert الطبيعي بدلًا من دوال تحقق طويلة. أيضًا يوفّر أدوات قوية مثل fixtures وparametrize وmonkeypatch، ويعمل بشكل ممتاز مع العديد من الإضافات. لهذا السبب، تجد pytest حاضرًا بقوة في المشاريع الصغيرة والكبيرة على حد سواء.

هذا لا يعني أن unittest سيئ أو قديم. بالعكس، فهمه مهم جدًا، لأنه يعلّمك المبادئ الأساسية للاختبار: test case، test suite، setup، teardown، assertions، العزل، والتنظيم. لكن عندما تبدأ بالعمل على مشروع حقيقي، ستشعر غالبًا أن pytest يعطيك سرعة في الكتابة وراحة في القراءة وتجربة أكثر أناقة في التوسّع.

يمكن تلخيص الفارق النفسي بينهما هكذا: unittest يقول لك “اتبع هذه البنية”، بينما pytest يقول لك “اكتب الاختبار كما تفكر فيه، وأنا سأساعدك على تنظيمه”. وفي أغلب المشاريع الحديثة، هذا الفارق مهم جدًا، خصوصًا عندما يكون الفريق يريد كتابة عدد كبير من الاختبارات دون أن تتحول الملفات إلى متاهة.


كيف نفكر في الاختبار قبل كتابة الكود؟

من الأخطاء الشائعة أن يبدأ المطور في كتابة الوظائف ثم يقول في النهاية: “سأضيف الاختبارات لاحقًا”. هذه الجملة تبدو بريئة، لكنها غالبًا تؤدي إلى تأجيل غير محدود. الأفضل هو أن تسأل نفسك منذ البداية: ما الذي يمكن أن يفشل هنا؟ وما الذي يجب أن أضمنه؟ وكيف أستطيع أن أراقب سلوك الكود من الخارج دون الدخول في التفاصيل الداخلية؟

عندما تفكر بهذه الطريقة، تتحول كتابة الكود من مجرد تنفيذ إلى تصميم. مثلًا، إذا كانت لديك دالة تحسب الخصم، فالمطلوب ليس فقط أن تعمل مع حالة واحدة، بل أن تتعامل مع حالات متعددة: السعر صفر، الخصم السالب، الخصم الكبير جدًا، القيم العشرية، والحدود الدنيا والقصوى. وهنا تظهر قيمة الاختبار: أنت لا تختبر فقط “الحالة السعيدة”، بل تختبر كل ما يمكن أن يكسر المنطق.

لذلك، قبل أن تكتب أول سطر اختبار، من المفيد أن ترسم في ذهنك ثلاثة أمور: ما هي المدخلات؟ ما هي المخرجات المتوقعة؟ وما هي الحالات الخاصة أو الحدودية؟ هذه الطريقة ليست فقط مفيدة في الاختبار، بل في البرمجة كلها. وكلما كانت الإجابة واضحة، كانت الاختبارات أقوى وأكثر واقعية.


أول مثال بسيط باستخدام unittest

لنبدأ بمثال صغير وواضح. افترض أننا نملك دالة تجمع رقمين:

# calculator.py
def add(a, b):
    return a + b

الاختبار البسيط لها قد يبدو كالتالي:

# test_calculator.py
import unittest
from calculator import add

class TestCalculator(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

if __name__ == "__main__":
    unittest.main()

هذا المثال بسيط جدًا، لكنه مهم لأنه يعرّفك على البنية الأساسية. لدينا class يرث من unittest.TestCase، وداخله method تبدأ بـ test_. هذه التسمية ليست شكلية، بل هي الطريقة التي يعرف بها unittest أن هذا أسلوب اختبار يجب تشغيله.

لكن إذا توقفنا هنا، سنكون قد فهمنا فقط الخطوات الأولى. ما هو أهم من ذلك هو فهم لماذا هذا الاختبار جيد: لأنه يختبر وظيفة محددة، لا يعتمد على أي شيء خارجي، ونتيجته واضحة جدًا. إذا تغيّرت الدالة وأصبح السلوك مختلفًا، سيخبرك الاختبار فورًا.

والجميل في unittest أنه يوفّر لك الكثير من أدوات التحقق مثل assertEqual, assertTrue, assertFalse, assertRaises، وغيرها. بهذه الأدوات يمكنك تغطية أغلب الحالات التي تحتاجها، خاصة عندما يكون المشروع كبيرًا أو عندما يكون الفريق معتادًا على هذا النمط.


الانتقال إلى pytest ولماذا يحبّه المطورون

الآن لنكتب المثال نفسه باستخدام pytest:

# calculator.py
def add(a, b):
    return a + b
# test_calculator.py
from calculator import add

def test_add():
    assert add(2, 3) == 5

لاحظ الفرق. لا class، لا وراثة، لا self.assertEqual. فقط دالة عادية وassert مباشرة. هذا وحده يخفف كثيرًا من الضجيج الذهني عند كتابة الاختبارات. أنت تكتب ما تريد التحقق منه بشكل طبيعي جدًا، وكأنك تقول: “توقع أن نتيجة 2 + 3 هي 5”. هذا الأسلوب يجعل الاختبار سهل القراءة حتى لمن يفتح الملف لأول مرة.

لكن pytest لا يتوقف عند البساطة. قوته الحقيقية تظهر حين تبدأ تستخدم fixtures لتنظيم البيانات المشتركة، وparametrize لتشغيل نفس الاختبار على عدة حالات، وmonkeypatch لعزل الاعتماديات الخارجية، وplugins لتوسيع قدراته. لهذا السبب، تجد الكثير من المطورين ينتقلون سريعًا من مجرد كتابة اختبارات بسيطة إلى بناء بنية اختبار متكاملة داخل المشروع كله.

المعنى الحقيقي لـ pytest ليس أنه “بديل” لـ unittest فقط، بل أنه طريقة عملية لتقليل الاحتكاك بينك وبين الاختبار. عندما يقل الاحتكاك، تكتب اختبارات أكثر. وعندما تكتب اختبارات أكثر، يتحسن التصميم، وتزداد الثقة، وتصبح الصيانة أسهل بكثير.


تنظيم مشروع Python للاختبارات

في البداية قد تكتب كل شيء في ملف واحد، وهذا طبيعي في التجارب الصغيرة. لكن عندما ينمو المشروع، يصبح التنظيم مهمًا جدًا. من الأفضل أن تفصل الكود الإنتاجي عن ملفات الاختبار، وأن تحافظ على هيكل واضح يسهل على الفريق فهمه.

مثال بسيط للبنية:

project/
├── app/
│   ├── __init__.py
│   ├── calculator.py
│   └── user_service.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   └── test_user_service.py
├── pyproject.toml
└── README.md

هذا التنظيم يجعل كل شيء واضحًا: الكود داخل app، والاختبارات داخل tests. ويمكنك لاحقًا إضافة fixtures عامة، أو بيانات اختبار، أو إعدادات خاصة لـ pytest.

في كثير من المشاريع، يفضل المطورون أيضًا استخدام ملف pyproject.toml لضبط إعدادات pytest. مثلًا:

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v"

هذا النوع من الإعدادات يجعل تشغيل الاختبارات أكثر وضوحًا، ويضمن أن pytest يعرف أين يبحث، وكيف يتعامل مع المسارات، وما مستوى التفاصيل الذي تريد رؤيته في output.

عندما يصبح المشروع أكبر، قد تجد نفسك بحاجة إلى تنظيم أدق: مجلد للاختبارات الوحدوية، ومجلد لاختبارات التكامل، ومجلد لاختبارات الأداء. هذا ليس شرطًا من البداية، لكنه مهم عندما يكون الفريق كبيرًا أو عندما يصبح التطبيق متعدد الطبقات.


كتابة اختبارات واضحة ومقروءة

الاختبار الجيد ليس فقط اختبارًا صحيحًا، بل اختبارًا مفهومًا. أحيانًا ترى اختبارات طويلة، مليئة بتفاصيل كثيرة، فتشعر أنها أصعب من الكود الذي تختبره! هذا خطأ شائع جدًا. الاختبار يجب أن يكون مباشرًا، وسهل القراءة، ويفضل أن يشرح نفسه بنفسه.

قاعدة جيدة هي أن يكون اسم الاختبار معبرًا. بدلًا من:

def test_a():
    ...

اكتب شيئًا مثل:

def test_add_returns_sum_of_two_numbers():
    ...

قد يبدو الاسم طويلًا قليلًا، لكنه يوفّر عليك الكثير عند تصفح مئات الاختبارات. لأنك لن تحتاج إلى فتح كل واحد منها لتفهم الفكرة. الاسم وحده يقول لك ماذا يختبر.

كما أن ترتيب الاختبار نفسه مهم. غالبًا نستخدم نمطًا بسيطًا:

  1. Arrange: تجهيز البيانات.

  2. Act: تنفيذ السلوك المطلوب.

  3. Assert: التحقق من النتيجة.

مثال:

def test_discount_calculation():
    price = 100
    discount = 20

    result = calculate_discount(price, discount)

    assert result == 80

هذا النمط يجعل الاختبار جميلًا وواضحًا. لا تضف تعقيدًا غير ضروري، ولا تخلط بين التحضير والتنفيذ والتحقق. إذا التزمت بهذا الأسلوب، ستجد أن الاختبارات أصبحت جزءًا من وثائق المشروع غير الرسمية أيضًا.


مثال واقعي: اختبار دالة لحساب الخصم

لنأخذ مثالًا أكثر واقعية. لنفترض أن لدينا دالة تحسب السعر بعد الخصم:

def calculate_final_price(price, discount_percent):
    return price - (price * discount_percent / 100)

قد نكتب لها اختبارات مثل هذه:

from pricing import calculate_final_price

def test_final_price_with_20_percent_discount():
    assert calculate_final_price(100, 20) == 80

def test_final_price_with_zero_discount():
    assert calculate_final_price(100, 0) == 100

def test_final_price_with_half_discount():
    assert calculate_final_price(200, 50) == 100

لكن هنا تبدأ الأسئلة الذكية: ماذا عن القيمة السالبة؟ ماذا عن الخصم الأكبر من 100؟ ماذا عن السعر غير الصالح؟ هذه الأسئلة ليست ترفًا، بل جوهر الاختبار الجيد. لأن دالة بسيطة جدًا قد تبدو آمنة، لكنها في الحقيقة قد تتصرف بشكل غير منطقي إذا أُعطيت مدخلات غير متوقعة.

يمكننا تحسين الدالة وإضافة تحقق:

def calculate_final_price(price, discount_percent):
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= discount_percent <= 100:
        raise ValueError("Discount must be between 0 and 100")

    return price - (price * discount_percent / 100)

ثم نختبر الأخطاء أيضًا:

import pytest
from pricing import calculate_final_price

def test_final_price_invalid_negative_price():
    with pytest.raises(ValueError, match="Price cannot be negative"):
        calculate_final_price(-10, 20)

def test_final_price_invalid_discount_above_100():
    with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
        calculate_final_price(100, 120)

هنا يظهر جمال الاختبار الحقيقي: نحن لا نتحقق فقط من السلوك الصحيح، بل من حماية الدالة لنفسها أيضًا. وهذا جزء مهم جدًا من برمجة بايثون الاحترافية.


استخدام pytest fixtures لتنظيم البيانات

واحدة من أقوى الميزات في pytest هي الـ fixtures. الفكرة ببساطة أنك تجهز شيئًا تحتاجه عدة اختبارات مرة واحدة، ثم تعيد استخدامه. هذا يقلل التكرار ويحسن التنظيم.

مثلًا، إذا كنت تحتاج إلى كائن User في عدة اختبارات:

# user_service.py
class User:
    def __init__(self, name, is_active=True):
        self.name = name
        self.is_active = is_active
# test_user_service.py
import pytest
from user_service import User

@pytest.fixture
def sample_user():
    return User(name="Ahmed", is_active=True)

def test_user_name(sample_user):
    assert sample_user.name == "Ahmed"

def test_user_is_active(sample_user):
    assert sample_user.is_active is True

هنا أنشأنا fixture باسم sample_user، ومررناه للاختبارات. أي اختبار يحتاج هذا المستخدم يستطيع ببساطة استدعاءه كـ argument. هذا يجنّبك تكرار نفس الإعدادات في كل مرة.

وفي المشاريع الحقيقية، قد تكون الـ fixture أكثر أهمية من الاختبار نفسه أحيانًا، لأنها تضمن لك بيئة ثابتة ومرنة. ويمكن أن تكون بسيطة أو معقدة، محلية أو عامة، ويمكن حتى أن تتعامل مع إعدادات مؤقتة أو قواعد بيانات اختبار.

الجميل في fixtures أنها تشبه النظام الخفي الذي يساعدك على بناء بيئة الاختبار دون أن يفسد وضوح الكود. كل شيء يصبح أكثر ترتيبًا، وكل اختبار يحصل فقط على ما يحتاجه بالفعل.


scope في fixtures ولماذا يهم

في pytest، يمكن للـ fixture أن يكون لها نطاق أو scope يحدد متى يتم إنشاؤها ومتى يتم إعادة استخدامها. هذا مهم جدًا في الأداء والتنظيم. فليس من المنطقي أن تنشئ قاعدة بيانات اختبار من الصفر لكل اختبار إذا كان بإمكانك إنشاؤها مرة واحدة لكل مجموعة اختبارات.

الأنواع الشائعة للـ scope هي: function, class, module, session.
مثلًا:

@pytest.fixture(scope="module")
def db_connection():
    connection = create_test_db()
    yield connection
    connection.close()

هذا يعني أن الاتصال بقاعدة البيانات سيتم إنشاؤه مرة واحدة لكل module من الاختبارات، ثم إغلاقه بعد الانتهاء. هذا يوفّر الوقت، لكنه يحتاج أيضًا إلى حذر لأن مشاركة الحالة بين الاختبارات قد تسبب مشاكل إذا لم تكن معزولة جيدًا.

عندما تعمل على مشروع حقيقي، قد تضطر للتوازن بين السرعة والعزل. أحيانًا الأفضل هو أن تكون كل حالة مستقلة حتى لو زاد الوقت قليلًا. وأحيانًا من المنطقي أن تشارك موردًا واحدًا إذا كان مكلفًا في الإنشاء. هذه القرارات جزء من النضج في كتابة الاختبارات.


parametrization: اختبار عدة حالات بكود أقل

من الرائع أن pytest يسمح لك بتشغيل نفس الاختبار على عدة قيم بسهولة باستخدام parametrize. هذا يختصر الكثير من التكرار، ويجعل الاختبارات أكثر شمولًا.

مثال:

import pytest
from pricing import calculate_final_price

@pytest.mark.parametrize(
    "price, discount, expected",
    [
        (100, 10, 90),
        (200, 25, 150),
        (50, 0, 50),
        (100, 100, 0),
    ]
)
def test_calculate_final_price(price, discount, expected):
    assert calculate_final_price(price, discount) == expected

هذا الأسلوب ممتاز عندما تريد اختبار نفس المنطق مع بيانات مختلفة. بدل أن تكتب أربع دوال متكررة، كتبت اختبارًا واحدًا واضحًا جدًا. وإذا فشلت حالة معينة، سيخبرك pytest عن السطر والقيمة التي سببت الفشل، مما يسهل التشخيص.

الـ parametrization مهم جدًا أيضًا عند اختبار القواعد المتكررة: التحقق من البريد الإلكتروني، التحقق من كلمة المرور، معالجة الأرقام، التواريخ، أو الصيغ النصية. إنه أداة تجعل الاختبار أقصر وأكثر ذكاءً.


اختبار الاستثناءات بشكل صحيح

في البرمجة الواقعية، ليس المهم فقط أن تعالج الحالة الناجحة، بل أن تعرف كيف تتعامل مع الحالات الخاطئة. لذا فإن اختبار الاستثناءات جزء أساسي جدًا من Unit Testing.

مثال:

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("b cannot be zero")
    return a / b

الاختبار:

import pytest
from math_ops import divide

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError, match="b cannot be zero"):
        divide(10, 0)

هذا النوع من الاختبارات مفيد لأنه يتحقق من أمرين في نفس الوقت: أن الاستثناء يُرفع فعلًا، وأن الرسالة التي ترافقه صحيحة. الرسالة ليست مجرد تفاصيل شكلية؛ أحيانًا تكون مهمة جدًا عند تتبع الأخطاء أو عند بناء API يعرض رسائل مناسبة للمستخدم.

لا تبالغ في استخدام الاستثناءات، لكن لا تهمل اختبارها أيضًا. فالمشروع الجيد هو الذي يتعامل مع الفشل بوضوح، لا الذي يتظاهر أن الفشل غير موجود.


Mocking: كيف نعزل الكود عن الاعتماديات الخارجية؟

عندما يكون الكود الذي نريد اختباره يعتمد على خدمة خارجية مثل API أو قاعدة بيانات أو بريد إلكتروني أو نظام ملفات، يصبح الاختبار المباشر مكلفًا وبطيئًا وغير مستقر. هنا نلجأ إلى mocking. الفكرة هي أن نستبدل الاعتماد الحقيقي بشيء وهمي نتحكم فيه بالكامل.

مثلًا، لنفترض أن لدينا خدمة ترسل رسالة ترحيب بعد إنشاء مستخدم:

# notification_service.py
def send_email(email, subject, body):
    pass
# user_service.py
from notification_service import send_email

def create_user(name, email):
    # منطق إنشاء المستخدم هنا
    send_email(email, "Welcome", f"Hello {name}")
    return {"name": name, "email": email}

نريد اختبار create_user دون إرسال بريد حقيقي. باستخدام unittest.mock:

from unittest.mock import patch
from user_service import create_user

@patch("user_service.send_email")
def test_create_user_sends_welcome_email(mock_send_email):
    result = create_user("Ahmed", "ahmed@example.com")

    mock_send_email.assert_called_once_with(
        "ahmed@example.com",
        "Welcome",
        "Hello Ahmed"
    )
    assert result["name"] == "Ahmed"

هذا الاختبار لا يرسل أي بريد فعلي، لكنه يتأكد أن الدالة استدعت send_email بالقيم الصحيحة. هذه هي قوة mocking: تحافظ على الاختبار سريعًا ومضمونًا، وتمنع الاعتماد على خدمات خارجية قد تتعطل أو تتأخر.

في pytest يمكنك أيضًا استخدام monkeypatch أو مكتبات إضافية مثل pytest-mock، وكلها تهدف إلى جعل عزل الاعتماديات أكثر سهولة. لكن المهم هنا ليس الأداة فقط، بل الفهم: لا تختبر كل شيء دفعة واحدة إذا كان ذلك يخلق ضوضاء. اختبر الوحدة نفسها، واصنع بيئة اختبار متحكمًا بها.


متى نستخدم unittest ومتى نستخدم pytest؟

هذا سؤال عملي جدًا. الجواب ليس دائمًا أبيض أو أسود. إذا كنت تعمل على مشروع صغير جدًا أو مشروع يعتمد بشكل كبير على الأدوات الافتراضية في Python، فـ unittest قد يكون كافيًا ومناسبًا. وإذا كانت البيئة التنظيمية في الشركة مبنية عليه، فلا مشكلة أبدًا.

أما إذا كنت تبدأ مشروعًا جديدًا، أو تريد مرونة أكبر، أو تفضّل كتابة اختبارات سهلة القراءة والتنظيم، فـ pytest غالبًا سيكون أفضل خيار. إنه لا يمنعك من استخدام أفكار unittest، لكنه يمنحك أدوات أفضل وكتابة أخف وتوسعة أذكى.

كثير من الفرق تستخدم الاثنين معًا بشكل غير مباشر: قد تجد بعض الاختبارات القديمة مكتوبة بـ unittest، بينما الاختبارات الجديدة تُكتب بـ pytest. وهذا طبيعي جدًا. المهم هو أن يكون هناك معيار واضح داخل المشروع حتى لا تتحول الاختبارات إلى خليط غير منسق.

القرار النهائي غالبًا يعتمد على:

  • حجم المشروع.

  • خبرة الفريق.

  • الحاجة إلى fixtures وparametrization.

  • وجود اختبارات قديمة مبنية على unittest.

  • تفضيل أسلوب كتابة بسيط وسلس.

في المشاريع الحديثة، شخصيًا أو مهنيًا، سيجد أغلب المطورين أن pytest يقدّم تجربة أكثر راحة على المدى الطويل.


أخطاء شائعة في اختبار Python

هناك مجموعة من الأخطاء تتكرر عند تعلم الاختبار، وبعضها قد يبدو بسيطًا لكنه يسبب مشاكل كبيرة لاحقًا.

أول خطأ هو اختبار التفاصيل الداخلية بدل السلوك الخارجي. مثلًا، لا تختبر فقط أن متغيرًا داخليًا تغيّر، بل اختبر أن الدالة أعادت النتيجة الصحيحة. الاختبارات التي ترتبط كثيرًا بالتفاصيل الداخلية تصبح هشة جدًا، وأي refactor بسيط يكسرها حتى لو كان السلوك النهائي صحيحًا.

الخطأ الثاني هو الإكثار من الاعتماد على موارد خارجية حقيقية. قاعدة البيانات الحقيقية، API الحقيقي، وملفات النظام الحقيقية قد تجعل الاختبارات بطيئة ومتقلبة. استخدمها حين تحتاج إلى integration tests، لكن لا تجعل كل الاختبارات تعتمد عليها.

الخطأ الثالث هو كتابة اختبارات غامضة وغير واضحة. أحيانًا يكون الاختبار موجودًا لكن لا أحد يفهمه. هذا أسوأ من عدم وجوده أحيانًا، لأنّه يخلق وهم الأمان. اجعل الاختبارات قصيرة، مقروءة، وذات أسماء واضحة.

الخطأ الرابع هو تجاهل الحالات الحدّية. كثير من الأعطال لا تظهر في المدخلات الطبيعية، بل في القيم الفارغة، أو الكبيرة جدًا، أو غير المتوقعة، أو null-equivalent. الاختبار الجيد ينظر إلى أطراف النظام، لا إلى وسطه فقط.

الخطأ الخامس هو اعتبار الاختبار عبئًا. الحقيقة أنه استثمار. نعم، يحتاج وقتًا في البداية، لكنه يوفّر وقتًا كبيرًا لاحقًا، خصوصًا عندما يبدأ المشروع بالنمو.


اختبار الكود القائم على الكائنات OOP

في Python، كثير من التطبيقات تُبنى بطريقة كائنية. وهنا تزداد أهمية الاختبار لأن classes قد تحتوي على state، methods متعددة، واعتماديات متبادلة.

مثال:

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient balance")
        self.balance -= amount

الاختبارات:

import pytest
from bank import BankAccount

def test_deposit_increases_balance():
    account = BankAccount(100)
    account.deposit(50)
    assert account.balance == 150

def test_withdraw_decreases_balance():
    account = BankAccount(100)
    account.withdraw(30)
    assert account.balance == 70

def test_withdraw_more_than_balance_raises_error():
    account = BankAccount(100)
    with pytest.raises(ValueError, match="Insufficient balance"):
        account.withdraw(200)

هنا نرى كيف يتحول الاختبار إلى سجل صغير لسلوك الكلاس. أي شخص يقرأ هذه الاختبارات سيفهم بسرعة كيف يتصرف BankAccount. وهذا بحد ذاته قيمة ضخمة. الاختبارات ليست فقط للتحقق، بل أيضًا لفهم النظام.


كيف تختبر الدوال التي تتعامل مع الوقت أو التاريخ؟

الوقت من أكثر الأشياء التي تسبب مشاكل في الاختبار. لماذا؟ لأنه متغير دائمًا، ويعتمد على الزمن الحقيقي، والاختبارات تحب الثبات.

لو كانت لديك دالة تعتمد على الوقت الحالي، فالأفضل أن تفصل الوصول إلى الوقت عن المنطق نفسه. بدلًا من استخدام datetime.now() مباشرة في كل مكان، مرّر الوقت كقيمة أو استعمل mocking.

مثال بسيط:

from datetime import datetime

def is_weekend(today=None):
    if today is None:
        today = datetime.now()
    return today.weekday() >= 5

الاختبار:

from datetime import datetime
from calendar_utils import is_weekend

def test_is_weekend_on_saturday():
    saturday = datetime(2025, 1, 4)  # Saturday
    assert is_weekend(saturday) is True

def test_is_weekend_on_monday():
    monday = datetime(2025, 1, 6)  # Monday
    assert is_weekend(monday) is False

هذا الأسلوب أفضل من الاعتماد على الوقت الحقيقي. كلما كانت مدخلاتك محددة، كانت اختباراتك أكثر ثباتًا. والاختبارات الثابتة هي التي تعيش طويلًا دون أن تزعجك.


اختبار الملفات وJSON وبيانات الإدخال

كثير من تطبيقات Python تتعامل مع ملفات JSON أو CSV أو نصوص قادمة من المستخدم. هنا يجب أن تكون الاختبارات دقيقة، لأن الأخطاء قد تكون مرتبطة بالبنية أو بالترميز أو بغياب حقول معينة.

مثال:

import json

def parse_user_data(raw_json):
    data = json.loads(raw_json)
    if "name" not in data:
        raise ValueError("name is required")
    return data["name"]

اختبارات:

import pytest
from parser import parse_user_data

def test_parse_user_data_returns_name():
    raw = '{"name": "Ahmed"}'
    assert parse_user_data(raw) == "Ahmed"

def test_parse_user_data_missing_name_raises_error():
    raw = '{"email": "ahmed@example.com"}'
    with pytest.raises(ValueError, match="name is required"):
        parse_user_data(raw)

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


الاختبار في المشاريع الكبيرة: ما الذي يتغير؟

عندما يكون المشروع صغيرًا، قد تشعر أن الاختبار مجرد ملفين أو ثلاثة. لكن عندما يكبر التطبيق، يصبح الاختبار جزءًا من البنية الأساسية. تبدأ بحاجة إلى:

  • تقسيم واضح بين unit tests وintegration tests.

  • fixtures مشتركة بعناية.

  • mocking مضبوط حتى لا يتحول المشروع إلى فوضى.

  • إعدادات مستقرة للتشغيل في CI/CD.

  • تقارير تغطية coverage.

  • معايير naming and formatting واضحة.

في هذه المرحلة، لا يكفي أن “تعمل الاختبارات”. يجب أن تكون قابلة للصيانة. لأن الفريق لن يتذكر دائمًا لماذا كُتب كل اختبار بعد ستة أشهر. إذا لم يكن الاختبار واضحًا، سيتحوّل إلى عبء. وإذا كان واضحًا، سيبقى داعمًا للمشروع لسنوات.

وهنا يظهر جمال الاختبار المنظّم: يسهّل onboarding للمطورين الجدد، ويقلل وقت مراجعة pull requests، ويجعل refactoring أقل توترًا. عندما ترى هذه الفوائد مجتمعة، تدرك أن الوقت الذي استثمرته في الاختبار لم يذهب هباءً أبدًا.


اختبار REST APIs في Python

إذا كنت تبني API باستخدام Flask أو Django أو FastAPI، فإن الاختبار يصبح أكثر أهمية. هنا لا تختبر مجرد دوال حسابية، بل تختبر endpoint كاملًا: request، response، status code، validation، authentication، وربما database interactions.

مثلًا في pytest يمكن اختبار endpoint بطريقة شبيهة بهذا:

def test_get_user(client):
    response = client.get("/users/1")
    assert response.status_code == 200
    assert response.json()["id"] == 1

هذه الفكرة تعتمد على وجود fixture اسمها client يوفّر test client. في Flask أو Django أو FastAPI ستجد أن لكل إطار طريقته الخاصة في إعداد client، لكن المبدأ واحد: أرسل request، واستقبل response، وتحقق من الشكل والسلوك.

في APIs، يجب أيضًا اختبار الحالات السلبية:

  • المستخدم غير موجود.

  • التوكن غير صالح.

  • البيانات ناقصة.

  • البريد الإلكتروني غير صحيح.

  • القيم خارج الحدود.

الاختبار الجيد للـ API لا يكتفي بأن “يرجع 200” في حالة النجاح، بل يتأكد أن الأخطاء أيضًا تُعرض بطريقة سليمة ومفهومة.


كيف تقيس جودة الاختبارات؟

ليس كل اختبار كثير يعني أن المشروع جيد. أحيانًا تجد مشروعًا مليئًا بالاختبارات، لكنها سطحية أو متكررة أو تركز على الشكل بدل المنطق. لذلك نحتاج أن نفكر في الجودة لا في العدد فقط.

من علامات الاختبار الجيد:

  • يختبر سلوكًا مهمًا.

  • سريع التنفيذ.

  • واضح الاسم والمحتوى.

  • لا يعتمد على موارد خارجية إلا عند الحاجة.

  • يلتقط الأخطاء الفعلية التي قد تقع في المشروع.

  • سهل التعديل عند refactoring.

ومن العلامات السيئة:

  • اختبار طويل جدًا ومربك.

  • استخدام fixtures بشكل عشوائي.

  • تكرار نفس الترتيب في كل ملف.

  • الاعتماد على network أو database حقيقية دون ضرورة.

  • ادعاء التغطية العالية دون معنى حقيقي.

يمكن أيضًا استخدام coverage tools لمعرفة أجزاء الكود غير المغطاة. لكن يجب أن نتذكر شيئًا مهمًا جدًا: التغطية العالية ليست هدفًا بحد ذاتها. الهدف هو الثقة في الكود. قد تصل إلى 90% coverage ومع ذلك يبقى التطبيق هشًا إذا كانت الاختبارات غير ذكية. وقد تكون التغطية 70% لكن الاختبارات مركزة جدًا ومفيدة. الجودة هنا أهم من الرقم.


الاختبار والتطوير بطريقة TDD

من الشائع أن نسمع عن TDD أو Test-Driven Development. الفكرة ببساطة أن تكتب الاختبار أولًا، ثم تكتب أقل قدر من الكود ليجعل الاختبار ينجح، ثم تحسن التصميم. هذه الطريقة ليست مناسبة لكل الناس أو لكل الظروف، لكنها مفيدة جدًا في بعض الحالات لأنها تجبرك على التفكير في السلوك قبل التنفيذ.

دورة TDD التقليدية هي:

  1. اكتب اختبارًا يفشل.

  2. اكتب الكود الأدنى الذي يجعله ينجح.

  3. حسن الكود مع الحفاظ على نجاح الاختبارات.

هذه الطريقة لها فوائد نفسية وتقنية. نفسيًا، تجعل الهدف واضحًا. وتقنيًا، تمنعك من كتابة كود زائد لا حاجة له. لكن حتى لو لم تتبن TDD بالكامل، فإن التفكير المستوحى منها مفيد جدًا: اكتب الاختبارات مبكرًا، لا متأخرًا، واجعلها ترشد تصميمك.


مثال كامل صغير يجمع الفكرة

لننشر مثالًا مصغّرًا يجمع الحساب، التحقق، والاختبار:

# shipping.py
def calculate_shipping(weight, destination):
    if weight <= 0:
        raise ValueError("Weight must be positive")

    base_cost = 5

    if destination == "local":
        return base_cost + (weight * 1)
    elif destination == "international":
        return base_cost + (weight * 5)
    else:
        raise ValueError("Invalid destination")

الاختبارات:

import pytest
from shipping import calculate_shipping

def test_local_shipping():
    assert calculate_shipping(3, "local") == 8

def test_international_shipping():
    assert calculate_shipping(2, "international") == 15

def test_invalid_weight_raises_error():
    with pytest.raises(ValueError, match="Weight must be positive"):
        calculate_shipping(0, "local")

def test_invalid_destination_raises_error():
    with pytest.raises(ValueError, match="Invalid destination"):
        calculate_shipping(2, "space")

هذا المثال الصغير يعكس منطقًا مهمًا: كل قاعدة business rule يجب أن تقابلها اختبارات. عندما تتغير القاعدة، يتغير الاختبار، وعندما يختل السلوك، يكتشفه الاختبار.


نصائح عملية لتصبح أفضل في اختبار Python

الاختبار مهارة تُبنى بالتدريج، وليس حفظًا لنحو معين. مع الوقت ستلاحظ أن بعض العادات تجعل الحياة أسهل بكثير. ابدأ بتجزئة الكود إلى وحدات صغيرة يسهل اختبارها. عندما تكون الدالة أو الكلاس واضحين، تصبح الاختبارات بسيطة أيضًا. حاول أن تجعل كل اختبار يختبر فكرة واحدة، لا مجموعة أفكار متداخلة. وإذا وجدت نفسك تكتب إعدادًا متكررًا، فكر في fixture بدلًا من النسخ واللصق.

من الحكمة أيضًا أن تختبر الحواف، لا الوسط فقط. كثير من الأخطاء تظهر في القيم الفارغة، أو الأعداد السالبة، أو السلاسل الفارغة، أو المصفوفات الكبيرة جدًا. لا تهمل هذه الحالات. كذلك، لا تربط الاختبار برسائل output غير ثابتة إلا إذا كانت الرسالة مهمة فعلًا. ركّز على السلوك الأساسي.

ومن النصائح المهمة جدًا أن تراجع اختباراتك كما تراجع الكود الإنتاجي. الاختبار ليس كودًا ثانويًا. إذا كان الاختبار سيئًا، فهو قد يضللك بدلًا من أن يساعدك. لذلك اعتنِ به، وسمّه بوضوح، واجعله بسيطًا، واعتبره جزءًا من التصميم العام للمشروع.


متى لا يكون الاختبار الوحدوي كافيًا؟

رغم قوة Unit Testing، فهو ليس كل شيء. بعض الأخطاء لا تظهر إلا عندما تتفاعل أجزاء متعددة مع بعضها. لذلك تحتاج غالبًا إلى طبقات إضافية من الاختبار، مثل integration tests وend-to-end tests. الاختبار الوحدوي يطمئنك على كل قطعة، لكن لا يضمن وحده أن القطع كلها تعمل معًا دون مشاكل.

مثلًا، قد تختبر دالة التحقق من البريد الإلكتروني بنجاح، لكن قد يظل API يفشل لأن إعدادات قاعدة البيانات خاطئة، أو لأن request parsing غير صحيح، أو لأن التنسيق بين طبقات النظام غير سليم. لهذا السبب، المشروع الناضج يجمع بين عدة أنواع من الاختبار. Unit tests للمنطق الداخلي، integration tests للعلاقات بين الوحدات، وend-to-end لاختبار رحلة المستخدم الحقيقية.

الذكاء هنا هو في توزيع الجهد. لا تضع كل ثقل الاختبار في طبقة واحدة. ولا تترك المشروع بلا حماية. التوازن هو السر.


خاتمة

اختبار تطبيقات Python باستخدام pytest وunittest ليس موضوعًا تقنيًا فقط، بل هو ثقافة تطوير كاملة. عندما تتعلم كيف تكتب اختبارات جيدة، فأنت لا تحسن جودة الكود فقط، بل تحسن طريقة تفكيرك كمطور. تبدأ في رؤية النظام كعناصر صغيرة يمكن التحكم فيها، وتتعلم كيف تعزل الاعتماديات، وكيف تصمم الكود ليكون سهل التحقق، وكيف تواجه التغيير بثقة بدل القلق.

إذا كنت في بداية الطريق، فابدأ بخطوات بسيطة: اكتب اختبارات لدوال صغيرة، ثم جرّب pytest، ثم تعلّم fixtures وparametrize وmocking. لا تحاول أن تتقن كل شيء في يوم واحد. الاختبار مهارة تتفتح مع الممارسة، وكل ملف اختبار تكتبه اليوم هو استثمار في كل ساعة ستعمل فيها على هذا المشروع غدًا.

#Pytest #Unit Testing #اختبار Python #كتابة الاختبارات #TDD #test automation #pytest fixtures #mocking in Python #software quality #testing best practices #unittest Python

اشترك في نشرتنا البريدية

12k+

المشتركون

أسبوعيًا

التكرار

مجاني

دائمًا