اذهب إلى المحتوى

تطبيق عملي لتعلم جانغو - الجزء التاسع: اختبار تطبيق جانغو


Ola Abbas

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

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

يحتوي موقع المكتبة المحلية Local Library حاليًا على صفحات لعرض قوائم بجميع الكتب والمؤلفين، والعروض التفصيلية لعناصر Book و Author، وصفحة لتجديد عناصر BookInstance، وصفحات لإنشاء عناصر Author وتحديثها وحذفها، إضافةً إلى سجلات Book أيضًا إذا أكملت قسم التحدي في المقال السابق.

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

يمكن أن تساعد الاختبارات الآلية في حل هذه المشكلة، إذ يمكن تشغيلها بصورة أسرع بكثير من الاختبارات اليدوية، ويمكن اختبارها بمستوًى أقل من التفاصيل، واختبار الوظيفة نفسها تمامًا في كل مرة (لا يُعَد المختبرون البشر قريبين من هذه الموثوقية). يمكن تنفيذ هذه الاختبارات اليدوية بصورة أكثر انتظامًا لأنها سريعة، وإذا فشل الاختبار، فسيؤشّر بدقة إلى المكان الذي لا تعمل فيه الشيفرة البرمجية كما هو متوقع.

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

يوضح هذا المقال كيفية كتابة الاختبارات الآلية لإطار عمل جانغو من خلال إضافة عدد من الاختبارات إلى موقع المكتبة المحلية LocalLibrary.

تتألف هذه السلسلة الفرعية من السلسلة الأشمل تعلم تطوير الويب من المقالات التالية:

أنواع الاختبارات

هناك العديد من الأنواع والمستويات والتصنيفات للاختبارات وأساليبها، وأهم الاختبارات الآلية هي:

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

ملاحظة: تشمل أنواع الاختبارات الشائعة الأخرى اختبارات الصندوق الأسود والصندوق الأبيض والاختبارات اليدوية والآلية واختبارات الكناري Canary والدخان Smoke والمطابقة والقبول والوظيفية والنظام والأداء والحِمل واختبارات الإجهاد.

ماذا يقدم جانغو لعملية الاختبار؟

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

يوفّر جانغو إطار عمل للاختبار مع تسلسل هرمي صغير من الأصناف التي تعتمد على مكتبة بايثون المعيارية unittest، إذ يُعَد إطار عمل الاختبار هذا مناسبًا لكل من اختبارات الوحدة والتكامل، فهو يضيف توابع وأدوات واجهة برمجة التطبيقات API للمساعدة في اختبار الويب وسلوك جانغو المُحدَّد، مما يسمح لك بمحاكاة الطلبات وإدخال بيانات الاختبار وفحص خرج تطبيقك. يوفر جانغو أيضًا واجهة برمجة تطبيقات (LiveServerTestCase) وأدوات لاستخدام أطر عمل اختبار مختلفة، فمثلًا يمكنك التكامل مع إطار عمل سيلينيوم Selenium الشهير لمحاكاة مستخدم يتفاعل مع متصفح مباشرةً.

يمكنك كتابة اختبار من خلال اشتقاق أيٍّ من أصناف اختبار جانغو الأساسية unittest، مثل SimpleTestCase و TransactionTestCase و TestCase و LiveServerTestCase، ثم كتابة توابع منفصلة للتحقق من أن وظيفة معينة تعمل كما هو متوقع، إذ تستخدم الاختبارات توابع "assert" لاختبار أن ناتج التعابير هو القيمة True أو القيمة False أو تساوي قيمتين أو غير ذلك.

ينفّذ إطار العمل توابع الاختبار المختارة في الأصناف المشتقة عند بدء تشغيل الاختبار، إذ تُشغَّل توابع الاختبار بطريقة مستقلة مع سلوك الإعداد و/أو الهدم tear-down الشائع المُعرَّف في الصنف كما هو موضح فيما يلي:

class YourTestClass(TestCase):
    def setUp(self):
        # تشغيل الإعداد قبل كل تابع اختبار
        pass

    def tearDown(self):
        # تنظيف التشغيل بعد كل تابع اختبار
        pass

    def test_something_that_will_pass(self):
        self.assertFalse(False)

    def test_something_that_will_fail(self):
        self.assertTrue(False)

أفضل صنف أساسي لمعظم الاختبارات هو الصنف django.test.TestCase، الذي ينشئ قاعدة بيانات نظيفة قبل تشغيل اختباراته، ويشغِّل كل دالة اختبار في تعاملاته الخاصة. يمتلك هذا الصنف أيضًا صنف الاختبار Client الذي يمكنك استخدامه لمحاكاة مستخدم يتفاعل مع الشيفرة البرمجية على مستوى العرض View. سنركز في الأقسام التالية على اختبارات الوحدة المُنشَأة باستخدام صنف TestCase الأساسي.

ملاحظة: يُعَد الصنف django.test.TestCase سهل الاستخدام، ولكنه يمكن أن يؤدي إلى أن تكون بعض الاختبارات أبطأ مما يجب، إذ لن يحتاج كل اختبار إلى إعداد قاعدة بياناته أو محاكاة تفاعل العرض. قد ترغب في استبدال بعض اختباراتك بأصناف الاختبار الأبسط المتاحة بعد أن تتعرف على ما يمكنك تطبيقه باستخدام هذا الصنف.

ماذا يجب أن تختبر؟

يجب أن تختبر جميع جوانب شيفرتك البرمجية، ولكن لا حاجة إلى اختبار أيّ مكتبات أو وظائف متوفرة مثل جزء من بايثون أو جانغو.

ليكن لدينا مثلًا نموذج المؤلف Author الآتي، فلا حاجة لاختبار التخزين الصحيح للحقلين first_name و last_name بوصفهما حقلين من النوع CharField في قاعدة البيانات صراحةً لأن ذلك شيء حدّده جانغو، بالرغم من أنك حتمًا ستختبر هذه الوظيفة أثناء التطوير، ولا حاجة أيضًا لاختبار التحقق من أن الحقل date_of_birth حقل تاريخ، لأنه شيء طبّقه جانغو، لكن ينبغي التحقق من النص المستخدم مع التسميات "First name" و "Last name" و "Date of birth" و "Died" وحجم الحقل المخصص للنص (100 محرف)، لأنها جزء من تصميمك وشيء يمكن أن يُكسَر أو يتغيّر في المستقبل.

class Author(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    date_of_birth = models.DateField(null=True, blank=True)
    date_of_death = models.DateField('Died', null=True, blank=True)

    def get_absolute_url(self):
        return reverse('author-detail', args=[str(self.id)])

    def __str__(self):
        return '%s, %s' % (self.last_name, self.first_name)

ينبغي أيضًا التحقق من أن التابعين المُخصَّصين get_absolute_url()‎ و ‎__str__()‎ يتصرفان بالطريقة المطلوبة لأنهما يمثلان شيفرتك أو منطق عملك. يمكنك في حالة التابع get_absolute_url()‎ الوثوق بتقديم تابع جانغو reverse()‎ بصورة صحيحة، لذا فإنّ ما تختبره هو تعريف العرض المرتبط به.

ملاحظة: يمكن أن يلاحظ القراء المتمرسون أننا نرغب في تقييد تاريخ الميلاد والوفاة بقيم مناسبة، والتحقق من أن الوفاة تأتي بعد الميلاد، ويمكن تحقيق هذا التقييد في جانغو من خلال إضافته إلى أصناف استمارتك. يمكنك تعريف أدوات تحقق لحقول النموذج وأدوات تحقق من النماذج نفسها، ولكنها تُستخدَم فقط على مستوى الاستمارة إذا استدعاها التابع clean()‎ الخاص بالنموذج، وهذا يتطلب الصنف ModelForm، أو استدعاء التابع clean()‎ الخاص بالنموذج على وجه التحديد.

لنبدأ الآن في التعرّف على كيفية تعريف الاختبارات وتشغيلها.

نظرة عامة على بنية الاختبار

لنلقِ أولًا نظرة سريعة على مكان وكيفية تعريف الاختبارات قبل أن ندخل في تفاصيل ما الذي يجب اختباره.

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

catalog/
  /tests/
    __init__.py
    test_models.py
    test_forms.py
    test_views.py

أنشئ بنية الملفات السابقة في مشروع المكتبة المحلية LocalLibrary، وينبغي أن يكون الملف "‎init.py" فارغًا، مما يخبر بايثون أن المجلد هو حزمة package. يمكنك إنشاء ملفات الاختبار الثلاثة من خلال نسخ وإعادة تسمية ملف الاختبار الهيكلي "‎/catalog/tests.py".

ملاحظة: أُنشِئ ملف اختبار الهيكلي ‎/catalog/tests.py تلقائيًا عند بناء موقع ويب جانغو الهيكلي، إذ يُعَد وضع جميع اختباراتك ضمنه أمرًا "مسموحًا به"، ولكن إذا أجريت الاختبار بصورة صحيحة، فسينتهي بك الأمر سريعًا مع ملف اختبار كبير جدًا ولا يمكن إدارته، لذا احذف الملف الهيكلي لأننا لن نحتاج إليه.

افتح الملف "‎/catalog/tests/test_models.py"، الذي ينبغي أن يستورد الصنف django.test.TestCase كما يلي:

from django.test import TestCase

# أنشئ اختباراتك هنا

ستضيف غالبًا صنفَ اختبار لكل نموذج/عرض/استمارة تريد اختبارها باستخدام توابع فردية لاختبار وظائف معينة، ويمكن أن ترغب في حالات أخرى في الحصول على صنف منفصل لاختبار حالة استخدام محددة باستخدام دوال اختبار فردية تختبر جوانب حالة الاستخدام، مثل صنف اختبار صحة حقل النموذج بصورة صحيحة باستخدام دوال لاختبار كلٍّ من حالات الفشل المحتملة. تُعَد البنية أمرًا متروكًا لك، ولكن يُفضَّل أن تكون متناسقة.

أضِف صنف الاختبار التالي إلى نهاية الملف، إذ يوضح هذا الصنف كيفية بناء صنف حالة اختبار من خلال اشتقاق الصنف TestCase:

class YourTestClass(TestCase):
    @classmethod
    def setUpTestData(cls):
        print("setUpTestData: Run once to set up non-modified data for all class methods.")
        pass

    def setUp(self):
        print("setUp: Run once for every test method to setup clean data.")
        pass

    def test_false_is_false(self):
        print("Method: test_false_is_false.")
        self.assertFalse(False)

    def test_false_is_true(self):
        print("Method: test_false_is_true.")
        self.assertTrue(False)

    def test_one_plus_one_equals_two(self):
        print("Method: test_one_plus_one_equals_two.")
        self.assertEqual(1 + 1, 2)

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

  • setUpTestData()‎: يُستدعَى مرةً واحدة في بداية تشغيل الاختبار للإعداد على مستوى الصنف، ويمكنك استخدامه لإنشاء كائنات لن تُعدَّل أو تُغيَّر في أيٍّ من توابع الاختبار.
  • setUp()‎: يُستدعَى قبل دوال الاختبار لإعداد الكائنات التي يمكن أن يعدّلها الاختبار، وستحصل كل دالة اختبار على نسخة "جديدة" من هذه الكائنات.

ملاحظة: تحتوي أصناف الاختبار على تابع tearDown()‎ التي لم نستخدمه، ولا يُعَد هذا التابع مفيدًا بخاصة لاختبارات قاعدة البيانات، لأن الصنف الأساسي TestCase يهتم بهدم قاعدة البيانات نيابةً عنك.

لدينا فيما يلي عددٌ من توابع الاختبار التي تستخدم دوال Assert لاختبار ما إذا كانت الشروط صحيحة أو خاطئة أو متساوية، وهي: AssertTrue و AssertFalse و AssertEqual. إذا لم يُقيَّم الشرط كما هو متوقع، فسيفشل الاختبار وسيرسَل الخطأ إلى طرفيتك.

تُعَد AssertTrue و AssertFalse و AssertEqual تأكيدات معيارية توفّرها المكتبة unittest، وهناك تأكيدات معيارية أخرى في إطار العمل، وتأكيدات خاصة بجانغو لاختبار ما إذا كان العرض يعيد التوجيه assertRedirects، أو يستخدم قالب معين assertTemplateUsed وغير ذلك.

ملاحظة: لا ينبغي عادةً تضمين دوال print()‎ في اختباراتك كما هو موضح سابقًا، لكننا نطبّق ذلك في القسم التالي فقط لتتمكّن من رؤية ترتيب استدعاء دوال الإعداد في الطرفية.

كيفية تشغيل الاختبارات

أسهل طريقة لتشغيل جميع الاختبارات هي استخدام الأمر التالي:

python3 manage.py test

سيؤدي هذا الأمر إلى اكتشاف جميع الملفات المُسمَّاة باستخدام النمط test*.py في المجلد الحالي وتشغيل جميع الاختبارات التي تعرّفها أصناف أساسية مناسبة، فلدينا هنا عدد من ملفات الاختبار، ولكن يحتوي الملف ‎/catalog/tests/test_models.py فقط على الاختبارات حاليًا. ستقدم الاختبارات افتراضيًا تقريرًا فرديًا فقط عن حالات فشل الاختبار ويتبعه ملخص الاختبار.

ملاحظة: إذا حصلتَ على أخطاء مشابهة للخطأ التالي:

ValueError: Missing staticfiles manifest entry...‎

يمكن أن يكون هذا الخطأ بسبب عدم تشغيل الاختبار للأمر collectstatic افتراضيًا، وبسبب أن تطبيقك يستخدم صنف تخزين يتطلب ذلك. اطلع على manifest_strict لمزيد من المعلومات. هناك عدد من الطرق التي يمكنك من خلالها التغلب على هذه المشكلة، وأسهلها هو تشغيل الأمر collectstatic قبل تشغيل الاختبارات كما يلي:

python3 manage.py collectstatic

شغّل الاختبارات في المجلد الجذر لمشروع المكتبة المحلية LocalLibrary، ويجب أن ترى خرجًا مثل الخرج التالي:

> python3 manage.py test

Creating test database for alias 'default'...
setUpTestData: Run once to set up non-modified data for all class methods.
setUp: Run once for every test method to setup clean data.
Method: test_false_is_false.
setUp: Run once for every test method to setup clean data.
Method: test_false_is_true.
setUp: Run once for every test method to setup clean data.
Method: test_one_plus_one_equals_two.
.
======================================================================
FAIL: test_false_is_true (catalog.tests.tests_models.YourTestClass)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\GitHub\django_tmp\library_w_t_2\locallibrary\catalog\tests\tests_models.py", line 22, in test_false_is_true
    self.assertTrue(False)
AssertionError: False is not true

----------------------------------------------------------------------
Ran 3 tests in 0.075s

FAILED (failures=1)
Destroying test database for alias 'default'...

نرى في الخرج السابق أن لدينا فشل اختبار واحد، ويمكننا أن نرى فعلًا الدالة التي فشلت وسبب فشلها، إذ يُعَد هذا الفشل متوقعًا، لأن القيمة False ليست القيمة True.

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

يُظهِر خرج دوال print()‎ كيفية استدعاء تابع setUpTestData()‎ مرةً واحدةً للصنف واستدعاء التابع setUp()‎ قبل كل تابع. تذكّر أنك لن تضيف عادةً هذا النوع من دوال print()‎ إلى اختباراتك.

توضّح الأقسام التالية كيفية تشغيل اختبارات محددة، وكيفية التحكم في مقدار المعلومات التي تعرضها الاختبارات.

عرض مزيد من معلومات الاختبار

إذا أردتَ الحصول على مزيد من المعلومات حول تشغيل الاختبار، فيمكنك تغيير قيمة verbosity، فمثلًا يمكنك ضبطها على القيمة "2" لسرد حالات نجاح الاختبار إضافةً إلى حالات الفشل ومجموعة كاملة من المعلومات حول كيفية إعداد قاعدة بيانات الاختبار:

python3 manage.py test --verbosity 2

مستويات verbosity المسموح بها هي: 0 و 1 و 2 و 3 والقيمة الافتراضية هي "1".

تسريع الاختبارات

إذا كانت اختباراتك مستقلة، فيمكنك تسريعها على جهاز متعدد المعالجات من خلال تشغيلها على التوازي، إذ يؤدي استخدام الأمر test ‎--parallel auto الآتي إلى تشغيل عملية اختبار واحدة لكل نواة متاحة، ويُعَد استخدام auto اختياريًا، ويمكنك تحديد عدد معين من الأنوية لاستخدامها.

python3 manage.py test --parallel auto

اطّلع على DJANGO_TEST_PROCESSES لمزيدٍ من المعلومات، بما في ذلك ما يجب عليك فعله إن لم تكن اختباراتك مستقلة.

تشغيل اختبارات محددة

إذا أردتَ تشغيل مجموعة فرعية من اختباراتك، فيمكنك ذلك من خلال تحديد المسار النقطي الكامل للحزمة (أو لمجموعة الحزم) أو الوحدة أو الصنف TestCase الفرعي أو التابع كما يلي:

# تشغيل الوحدة المُحدَّدة
python3 manage.py test catalog.tests

# تشغيل الوحدة المُحدَّدة
python3 manage.py test catalog.tests.test_models

# تشغيل الصنف المُحدَّد
python3 manage.py test catalog.tests.test_models.YourTestClass

# تشغيل التابع المُحدَّد
python3 manage.py test catalog.tests.test_models.YourTestClass.test_one_plus_one_equals_two

خيارات مشغل الاختبارات الأخرى

يوفر مشغِّل الاختبارات العديد من الخيارات الأخرى، بما في ذلك القدرة على خلط الاختبارات ‎--shuffle، وتشغيلها في وضع تنقيح الأخطاء ‎--debug-mode، واستخدام مسجِّل بايثون Python logger لالتقاط النتائج. اطلع على توثيق مشغّل اختبار جانغو لمزيد من المعلومات، كما يمكنك الاطلاع على المقال التالي كيف تستخدم التسجيل Logging في بايثون 3 على أكاديمية حسوب لمزيدٍ من المعلومات حوال التسجيل.

اختبارات موقع المكتبة المحلية LocalLibrary

نعرف الآن كيفية تشغيل اختباراتنا ونوع الأمور التي يجب اختبارها، ولنلقِ الآن نظرةً على بعض الأمثلة العملية.

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

النماذج Models

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

ليكن لدينا نموذج المؤلف Author الآتي، إذ يجب أن نختبر تسميات جميع الحقول، لأن لدينا تصميمًا يوضّح ما يجب أن تكون عليه هذه القيم بالرغم من أننا لم نحدّد معظمها صراحةً، فإن لم نختبر هذه القيم، فلن نعرف أن تسميات الحقول لها قيمها المقصودة الخاصة. وبالمثل، نعرف أن جانغو سيُنشِئ حقلًا بطول محدد، ولكن يُفضَّل تحديد اختبار لهذا الطول للتأكد من تطبيقه كما هو مُخطَّط له.

class Author(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    date_of_birth = models.DateField(null=True, blank=True)
    date_of_death = models.DateField('Died', null=True, blank=True)

    def get_absolute_url(self):
        return reverse('author-detail', args=[str(self.id)])

    def __str__(self):
        return f'{self.last_name}, {self.first_name}'

افتح الملف "‎/catalog/tests/test_models.py"، وضع شيفرة الاختبار الآتية لنموذج المؤلف Author مكان أي شيفرة برمجية موجودة مسبقًا، إذ سترى أننا نستورد الصنف TestCase أولًا ونشتق منه صنف الاختبار AuthorModelTest باستخدام اسم وصفي لنتمكّن بسهولة من تحديد أيّ اختبارات فاشلة في خرج الاختبار، ثم نستدعي التابع setUpTestData()‎ لإنشاء كائن المؤلف الذي سنستخدمه دون تعديل في أيٍّ من الاختبارات.

from django.test import TestCase

from catalog.models import Author

class AuthorModelTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # إعداد الكائنات غير المُعدَّلة التي تستخدمها جميع توابع الاختبار
        Author.objects.create(first_name='Big', last_name='Bob')

    def test_first_name_label(self):
        author = Author.objects.get(id=1)
        field_label = author._meta.get_field('first_name').verbose_name
        self.assertEqual(field_label, 'first name')

    def test_date_of_death_label(self):
        author = Author.objects.get(id=1)
        field_label = author._meta.get_field('date_of_death').verbose_name
        self.assertEqual(field_label, 'died')

    def test_first_name_max_length(self):
        author = Author.objects.get(id=1)
        max_length = author._meta.get_field('first_name').max_length
        self.assertEqual(max_length, 100)

    def test_object_name_is_last_name_comma_first_name(self):
        author = Author.objects.get(id=1)
        expected_object_name = f'{author.last_name}, {author.first_name}'
        self.assertEqual(str(author), expected_object_name)

    def test_get_absolute_url(self):
        author = Author.objects.get(id=1)
        # سيفشل هذا الاختبار أيضًا إن لم يكن‫ urlconf مُعرَّفًا
        self.assertEqual(author.get_absolute_url(), '/catalog/author/1')

تتحقق اختبارات الحقول من أن قيم تسميات الحقول verbose_name وأن حجم الحقول المحرفية كما هو متوقع. تمتلك جميع هذه التوابع أسماءً وصفية وتتبع النمط نفسه:

# الحصول على كائن مؤلف لاختباره
author = Author.objects.get(id=1)

# الحصول على البيانات الوصفية للحقل المطلوب واستخدامها للاستعلام عن بيانات الحقل المطلوبة
field_label = author._meta.get_field('first_name').verbose_name

# موازنة القيمة بالنتيجة المتوقعة
self.assertEqual(field_label, 'first name')

الأمور المهمة التي يجب ملاحظتها هي:

  • لا يمكننا الحصول على verbose_name مباشرةً باستخدام author.first_name.verbose_name، لأن author.first_name هو سلسلة نصية أي ليس مؤشرًا للكائن first_name الذي يمكننا استخدامه للوصول إلى خاصياته، لذا ينبغي بدلًا من ذلك استخدام السمة ‎_meta الخاصة بالمؤلف للحصول على نسخة من الحقل واستخدامها للاستعلام عن المعلومات الإضافية.
  • اخترنا استخدام assertEqual(field_label,'first name')‎ عوضًا عن assertTrue(field_label == 'first name')‎، والسبب في ذلك هو أن خرج assertEqual يخبرك عن التسمية الفعلية في حالة فشل الاختبار، مما يجعل تنقيح أخطاء المشكلة أسهل قليلًا.

ملاحظة: أُهمِلت اختبارات تسميات last_name و date_of_birth واختبار طول الحقل last_name، لذا ضِف نسخك الخاصة الآن باتباع اصطلاحات التسمية والأساليب الموضَّحة سابقًا.

يجب أيضًا اختبار توابعنا الخاصة، فمثلًا تتحقق الاختبارات التالية من بناء اسم الكائن كما توقعنا باستخدام تنسيق "Last Name", "First Name"، وأن عنوان URL الذي نحصل عليه لعنصر المؤلف Author كما هو متوقع:

def test_object_name_is_last_name_comma_first_name(self):
    author = Author.objects.get(id=1)
    expected_object_name = f'{author.last_name}, {author.first_name}'
    self.assertEqual(str(author), expected_object_name)

def test_get_absolute_url(self):
    author = Author.objects.get(id=1)
    # سيفشل هذا الاختبار أيضًا إن لم يكن‫ urlconf مُعرَّفًا
    self.assertEqual(author.get_absolute_url(), '/catalog/author/1')

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

======================================================================
FAIL: test_date_of_death_label (catalog.tests.test_models.AuthorModelTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\...\locallibrary\catalog\tests\test_models.py", line 32, in test_date_of_death_label
    self.assertEqual(field_label,'died')
AssertionError: 'Died' != 'died'
- Died
? ^
+ died
? ^

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

ملاحظة: عدّل تسمية الحقل date_of_death في الملف "‎/catalog/models.py" إلى "died" وأعِد تشغيل الاختبارات.

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

الاستمارات Forms

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

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

class RenewBookForm(forms.Form):
    """Form for a librarian to renew books."""
    renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

    def clean_renewal_date(self):
        data = self.cleaned_data['renewal_date']

        # تحقق من أن التاريخ ليس في الماضي
        if data < datetime.date.today():
            raise ValidationError(_('Invalid date - renewal in past'))

        # تحقق من أن التاريخ موجود ضمن المجال المسموح به (+4 أسابيع من اليوم‫)
        if data > datetime.date.today() + datetime.timedelta(weeks=4):
            raise ValidationError(_('Invalid date - renewal more than 4 weeks ahead'))

        # تذكّر دائمًا أن تعيد البيانات النظيفة
        return data

افتح الملف "‎/catalog/tests/test_forms.py" وضع الاختبار التالي لاستمارة RenewBookForm مكان أيّ شيفرة برمجية موجودة مسبقًا. نبدأ باستيراد استمارتنا وبعض مكتبات بايثون وجانغو للمساعدة في اختبار الوظائف المتعلقة بالوقت، ثم نصرّح عن صنف اختبار الاستمارة بالطريقة نفسها التي طبّقناها على النماذج باستخدام اسم وصفي لصنف الاختبار المشتق من الصنف TestCase.

import datetime

from django.test import TestCase
from django.utils import timezone

from catalog.forms import RenewBookForm

class RenewBookFormTest(TestCase):
    def test_renew_form_date_field_label(self):
        form = RenewBookForm()
        self.assertTrue(form.fields['renewal_date'].label is None or form.fields['renewal_date'].label == 'renewal date')

    def test_renew_form_date_field_help_text(self):
        form = RenewBookForm()
        self.assertEqual(form.fields['renewal_date'].help_text, 'Enter a date between now and 4 weeks (default 3).')

    def test_renew_form_date_in_past(self):
        date = datetime.date.today() - datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_too_far_in_future(self):
        date = datetime.date.today() + datetime.timedelta(weeks=4) + datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_today(self):
        date = datetime.date.today()
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())

    def test_renew_form_date_max(self):
        date = timezone.localtime() + datetime.timedelta(weeks=4)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())

تختبر الدالتان الأوليتان أن تسمية label ونص التعليمات help_text الخاصين بالحقل كما هو متوقع. ينبغي الوصول إلى الحقل باستخدام قاموس الحقول، مثل form.fields['renewal_date']‎. لاحظ أنه ينبغي اختبار ما إذا كانت قيمة التسمية هي None، لأن جانغو يعيد القيمة None إن لم تُضبَط قيمة التسمية صراحةً بالرغم من أنه سيعرض التسمية الصحيحة.

تختبر بقية الدوال أن الاستمارة صالحة لتواريخ التجديد ضمن المجال المقبول وغير صالحة للقيم الموجودة خارج المجال. لاحظ كيفية بناء قيم تاريخ الاختبار حول تاريخنا الحالي datetime.date.today()‎ باستخدام datetime.timedelta()‎ (تحديد عدد الأيام أو الأسابيع في هذه الحالة)، ثم ننشئ الاستمارة فقط، ونمرر بياناتنا، ونختبر فيما إذا كانت صالحة أم لا.

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

تحذير: إذا استخدمتَ صنف ModelForm الذي هو RenewBookModelForm(forms.ModelForm)‎ بدلًا من الصنف RenewBookForm(forms.Form)‎، فسيكون اسم حقل الاستمارة "due_back" بدلًا من "renewal_date".

هذا كل ما لدينا للتعامل مع الاستمارات وهناك بعض الأمور الأخرى، ولكن تنشِئها عروض التعديل المُعمَّمة المستندة إلى الأصناف Generic Class-based Editing Views تلقائيًا، ويجب اختبارها هناك، لذا يمكنك إجراء الاختبارات والتأكد من أن شيفرتك البرمجية لا تزال تعمل بنجاح.

العروض Views

يمكن التحقق من صحة سلوك العرض من خلال استخدام الصنف Client لاختبار جانغو، إذ يعمل هذا الصنف بوصفه متصفح ويب وهمي dummy web browser يمكننا استخدامه لمحاكاة طلبات GET و POST على عنوان URL ومراقبة الاستجابة. يمكننا أن نرى كل شيء متعلق بالاستجابة تقريبًا، بدءًا من HTTP منخفض المستوى، أي ترويسات النتائج ورموز الحالة، إلى القالب الذي نستخدمه لعرض صفحة HTML وبيانات السياق التي نمررها إليه، ويمكننا رؤية سلسلة عمليات إعادة التوجيه (إن وجدت) والتحقق من عنوان URL ورمز الحالة في كل خطوة، مما يسمح لنا بالتحقق من أن كل عرض يطبّق المتوقع منه.

لنبدأ بواحدٍ من أبسط العروض، والذي يوفّر قائمة بجميع المؤلفين، ويُعرَض على عنوان URL، الذي هو "/catalog/authors/" (عنوان URL الذي اسمه "authors" في ضبط عناوين URL):

class AuthorListView(generic.ListView):
    model = Author
    paginate_by = 10

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

افتح الملف "‎"/catalog/tests/test_views.py وضع فيه شيفرة الاختبار التالية للصنف AuthorListView بدلًا من أيّ نص آخر موجود فيه، إذ سنستورد نموذجنا وبعض الأصناف المفيدة، ونضبط في التابع setUpTestData()‎ عددًا من كائنات Author لنتمكّن من اختبار ترقيم الصفحات:

from django.test import TestCase
from django.urls import reverse

from catalog.models import Author

class AuthorListViewTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # إنشاء 13 مؤلفًا لاختبارات ترقيم الصفحات
        number_of_authors = 13

        for author_id in range(number_of_authors):
            Author.objects.create(
                first_name=f'Dominique {author_id}',
                last_name=f'Surname {author_id}',
            )

    def test_view_url_exists_at_desired_location(self):
        response = self.client.get('/catalog/authors/')
        self.assertEqual(response.status_code, 200)

    def test_view_url_accessible_by_name(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)

    def test_view_uses_correct_template(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'catalog/author_list.html')

    def test_pagination_is_ten(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertEqual(len(response.context['author_list']), 10)

    def test_lists_all_authors(self):
        # الحصول على الصفحة الثانية والتأكد من أنها تحتوي (بالضبط) على 3 عناصر متبقية
        response = self.client.get(reverse('authors')+'?page=2')
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertEqual(len(response.context['author_list']), 3)

تستخدم جميع الاختبارات العميل، الذي ينتمي إلى الصنف المشتق من TestCase لمحاكاة طلب GET والحصول على استجابة. تتحقق النسخة الأولى من عنوان URL المحدد (لاحظ وجود المسار المحدد فقط بدون النطاق)، بينما تولّد النسخة الثانية عنوان URL من اسمه في ضبط عناوين URL كما يلي:

response = self.client.get('/catalog/authors/')
response = self.client.get(reverse('authors'))

نستعلم عن الاستجابة بعد الحصول عليها لنحصل على رمز حالتها والقالب المُستخدَم وما إذا كانت الاستجابة مرقمة paginated أم لا وعدد العناصر المُعادة وعدد العناصر الإجمالي.

ملاحظة: إذا ضبطتَ المتغير paginate_by في الملف ‎/catalog/views.py إلى عدد آخر غير العدد 10، فتأكد من تحديث الأسطر التي تختبر عرضَ عدد العناصر الصحيح في القوالب ذات الصفحات المرقمة السابقة وفي الأقسام التالية، فمثلًا إذا ضبطتَ متغيرًا لصفحة قائمة المؤلفين على القيمة 5، فعدّل السطر السابق إلى ما يلي:

self.assertTrue(len(response.context['author_list']) == 5)

المتغير الأهم الذي عرضناه سابقًا هو response.context، وهو متغير السياق الذي يمرّره العرض إلى القالب، ويُعَد هذا المتغير مفيدًا للاختبار، لأنه يسمح لنا بالتأكد من أن قالبنا يحصل على جميع البيانات التي يحتاجها، أي يمكننا التحقق من أننا نستخدم القالب المقصود والبيانات التي يحصل عليها القالب، مما يؤدي إلى التحقق من رجوع أي مشكلات في التصيير rendering إلى القالب فقط.

العروض المقيدة على المستخدمين الذين سجلوا الدخول

سترغب في بعض الحالات في اختبار العرض المقتصر على المستخدمين الذين سجّلوا الدخول فقط، فالعرض LoanedBooksByUserListView مثلًا مشابه جدًا لعرضنا السابق، ولكنه متاحٌ فقط للمستخدمين الذين سجّلوا الدخول، ويعرض فقط سجلات نسخ الكتاب BookInstance التي استعارها المستخدم الحالي والتي لها الحالة 'on loan' ومرتبة بحسب الأقدم أولًا "oldest first".

from django.contrib.auth.mixins import LoginRequiredMixin

class LoanedBooksByUserListView(LoginRequiredMixin, generic.ListView):
    """Generic class-based view listing books on loan to current user."""
    model = BookInstance
    template_name ='catalog/bookinstance_list_borrowed_user.html'
    paginate_by = 10

    def get_queryset(self):
        return BookInstance.objects.filter(borrower=self.request.user).filter(status__exact='o').order_by('due_back')

ضِف شيفرة الاختبار الآتية إلى الملف "‎/catalog/tests/test_views.py"، إذ نستخدم هنا أولًا التابع SetUp()‎ لإنشاء بعض حسابات تسجيل دخول المستخدم وكائنات BookInstance -مع الكتب المرتبطة بها والسجلات الأخرى- التي سنستخدمها لاحقًا في الاختبارات. يستعير كل مستخدم تجريبي نصف الكتب، ولكننا ضبطنا في البداية حالة جميع الكتب على أنها قيد الصيانة "maintenance"، واستخدمنا التابع SetUp()‎ بدلًا من setUpTestData()‎ لأننا سنعدّل بعض هذه الكائنات لاحقًا.

ملاحظة: تنشئ شيفرة setUp()‎ التالية كتابًا بلغة Language محددة، ولكن يمكن ألّا تحتوي شيفرتك البرمجية على النموذج Language الذي تركناه بمثابة تحدٍ لك، لذا يمكنك تعليق أجزاء الشيفرة البرمجية التي تنشئ أو تستورد كائنات Language، ويجب أيضًا تطبيق ذلك في قسم RenewBookInstancesViewTest.

import datetime

from django.utils import timezone
from django.contrib.auth.models import User # مطلوب لضبط المستخدم بوصفه مستعيرًا

from catalog.models import BookInstance, Book, Genre, Language

class LoanedBookInstancesByUserListViewTest(TestCase):
    def setUp(self):
        # أنشئ مستخدمَين
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')

        test_user1.save()
        test_user2.save()

        # أنشئ كتابًا
        test_author = Author.objects.create(first_name='John', last_name='Smith')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )

        # ‫أنشئ نوع الكتاب genre لخطوة لاحقة
        genre_objects_for_book = Genre.objects.all()
        # الإسناد المباشر لأنواع متعدد إلى متعدد غير مسموح به
        test_book.genre.set(genre_objects_for_book) 
        test_book.save()

        # ‫أنشئ 30 كائن BookInstance
        number_of_book_copies = 30
        for book_copy in range(number_of_book_copies):
            return_date = timezone.localtime() + datetime.timedelta(days=book_copy%5)
            the_borrower = test_user1 if book_copy % 2 else test_user2
            status = 'm'
            BookInstance.objects.create(
                book=test_book,
                imprint='Unlikely Imprint, 2016',
                due_back=return_date,
                borrower=the_borrower,
                status=status,
            )

    def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('my-borrowed'))
        self.assertRedirects(response, '/accounts/login/?next=/catalog/mybooks/')

    def test_logged_in_uses_correct_template(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # تحقق من أن المستخدم قد سجل الدخول
        self.assertEqual(str(response.context['user']), 'testuser1')
        # تحقق من حصولنا على استجابة تمثل "النجاح‫"
        self.assertEqual(response.status_code, 200)

        # تحقق من استخدامنا للقالب الصحيح
        self.assertTemplateUsed(response, 'catalog/bookinstance_list_borrowed_user.html')

يمكن التحقق من أن العرض سيعيد توجيه المستخدم إلى صفحة تسجيل الدخول إن لم يسجّل دخول من خلال استخدام assertRedirects كما هو موضح في test_redirect_if_not_logged_in()‎. يمكن التحقق من إظهار الصفحة لمستخدم سجّل الدخول من خلال تسجيل الدخول للمستخدم التجريبي أولًا، ثم الوصول إلى الصفحة مرةً أخرى والتحقق من حصولنا على رمز الحالة status_code التي هي 200 وتمثل النجاح.

تتحقق بقية الاختبارات من أن عروضنا تعيد فقط الكتب المُعارة للمستعير الحالي. انسخ الشيفرة البرمجية التالية والصقها في نهاية صنف الاختبار السابق:

   def test_only_borrowed_books_in_list(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # تحقق من أن المستخدم قد سجّل الدخول
        self.assertEqual(str(response.context['user']), 'testuser1')
        # تحقق من حصولنا على استجابة تمثل "النجاح‫"
        self.assertEqual(response.status_code, 200)

        # تحقق من عدم وجود أيّ كتب في القائمة في البداية (‫لا توجد كتب مُعارة)
        self.assertTrue('bookinstance_list' in response.context)
        self.assertEqual(len(response.context['bookinstance_list']), 0)

        # غيّر الآن جميع الكتب لتكون مُعارة
        books = BookInstance.objects.all()[:10]

        for book in books:
            book.status = 'o'
            book.save()

        # تحقق من أننا قد استعرنا الكتب في القائمة
        response = self.client.get(reverse('my-borrowed'))
        # تحقق من أن المستخدم قد سجّل الدخول
        self.assertEqual(str(response.context['user']), 'testuser1')
        # تحقق من حصولنا على استجابة تمثل "النجاح‫"
        self.assertEqual(response.status_code, 200)

        self.assertTrue('bookinstance_list' in response.context)

        # ‫تأكد من أن جميع الكتب تعود إلى المستخدم testuser1 وأنها مُعارة
        for bookitem in response.context['bookinstance_list']:
            self.assertEqual(response.context['user'], bookitem.borrower)
            self.assertEqual(bookitem.status, 'o')

    def test_pages_ordered_by_due_date(self):
        # غيّر جميع الكتب لتكون مُعارة
        for book in BookInstance.objects.all():
            book.status='o'
            book.save()

        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # تحقق من أن المستخدم قد سجّل الدخول
        self.assertEqual(str(response.context['user']), 'testuser1')
        # تحقق من حصولنا على استجابة تمثل "النجاح‫"
        self.assertEqual(response.status_code, 200)

        # تأكد من عرض 10 عناصر فقط بسبب ترقيم الصفحات
        self.assertEqual(len(response.context['bookinstance_list']), 10)

        last_date = 0
        for book in response.context['bookinstance_list']:
            if last_date == 0:
                last_date = book.due_back
            else:
                self.assertTrue(last_date <= book.due_back)
                last_date = book.due_back

يمكنك أيضًا إضافة اختبارات لترقيم الصفحات Pagination إذا أردتَ ذلك.

اختبار العروض مع الاستمارات

يُعَد اختبار العرض مع الاستمارات أكثر تعقيدًا من الحالات السابقة، لأنه يجب اختبار مزيدٍ من مسارات الشيفرات البرمجية وهي: الإظهار الأولي، والإظهار بعد فشل التحقق من صحة البيانات، والإظهار بعد نجاح التحقق من صحة البيانات. والخبر السار هو أننا نستخدم العميل للاختبار باستخدام الطريقة نفسها التي استخدمناها لاختبار العروض المتاحة للعرض فقط.

لنكتب بعض اختبارات العرض المُستخدَم لتجديد الكتب renew_book_librarian()‎:

from catalog.forms import RenewBookForm

@permission_required('catalog.can_mark_returned')
def renew_book_librarian(request, pk):
    """View function for renewing a specific BookInstance by librarian."""
    book_instance = get_object_or_404(BookInstance, pk=pk)

    # إذا كان هذا الطلب من النوع‫ POST، فعالج بيانات الاستمارة
    if request.method == 'POST':

        # أنشئ نسخة من الاستمارة واملأها ببيانات من الطلب (الربط‫ Binding):
        book_renewal_form = RenewBookForm(request.POST)

        # تحقق من أن الاستمارة صالحة
        if form.is_valid():
            # ‫معالجة البيانات في form.cleaned_data كما هو مطلوب (نكتبها هنا فقط في حقل النموذج due_back)
            book_instance.due_back = form.cleaned_data['renewal_date']
            book_instance.save()

            # ‫إعادة التوجيه إلى عنوان URL جديد
            return HttpResponseRedirect(reverse('all-borrowed'))

    # إذا كان تابع‫ GET (أو أي تابع آخر)، فأنشئ الاستمارة الافتراضية
    else:
        proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
        book_renewal_form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

    context = {
        'book_renewal_form': book_renewal_form,
        'book_instance': book_instance,
    }

    return render(request, 'catalog/book_renew_librarian.html', context)

ينبغي اختبار أن العرض متاح فقط للمستخدمين الذين لديهم الإذن can_mark_returned، ويُعاد توجيه المستخدمين إلى صفحة خطأ HTTP 404 إذا حاولوا تجديد نسخة كتاب BookInstance ليست موجودة. ينبغي أن نتحقق من ضبط القيمة الأولية للاستمارة بتاريخ لثلاثة أسابيع في المستقبل، وأنه إذا نجحت عملية التحقق من صحة البيانات، فسيُعاد توجيهنا إلى عرض "جميع الكتب المستعارة"، وسنتحقق -كجزء من فحص اختبارات فشل التحقق من صحة البيانات- من أن استمارتنا ترسل رسائل الخطأ المناسبة.

أضِف الجزء الأول من صنف الاختبار الموضح فيما يلي إلى نهاية الملف "‎/catalog/tests/test_views.py"، إذ يؤدي ذلك إلى إنشاء مستخدمَين ونسختين من الكتاب، ولكنه يمنح مستخدمًا واحدًا فقط الإذن المطلوب للوصول إلى العرض:

import uuid

from django.contrib.auth.models import Permission # مطلوب لمنح الإذن اللازم لضبط كتاب على أنه مُعاد

class RenewBookInstancesViewTest(TestCase):
    def setUp(self):
        # أنشئ مستخدمًا
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')

        test_user1.save()
        test_user2.save()

        # امنح المستخدم‫ test_user2 الإذن لتجديد الكتب
        permission = Permission.objects.get(name='Set book as returned')
        test_user2.user_permissions.add(permission)
        test_user2.save()

        # أنشئ كتابًا
        test_author = Author.objects.create(first_name='John', last_name='Smith')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )

        # أنشئ نوع الكتاب‫ genre لخطوة لاحقة
        genre_objects_for_book = Genre.objects.all()
        # الإسناد المباشر لأنواع متعدد إلى متعدد غير مسموح به
        test_book.genre.set(genre_objects_for_book) 
        test_book.save()

        # أنشئ كائن‫ BookInstance للمستخدم test_user1
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance1 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user1,
            status='o',
        )

        # أنشئ كائن‫ BookInstance للمستخدم test_user2
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance2 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user2,
            status='o',
        )

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

  • عندما لا يسجّل المستخدم الدخول.
  • عندما يسجّل المستخدم الدخول ولكن ليس لديه الأذونات الصحيحة.
  • عندما يكون لدى المستخدم أذونات ولكنه ليس المستعير (يجب أن تنجح هذه الحالة).

كما سنتحقق مما سيحدث عندما يحاولون الوصول إلى نسخة كتاب BookInstance غير موجودة، وكذلك من استخدام القالب الصحيح.

  def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        # ‫تحقق يدويًا من إعادة التوجيه 
        # ‫لا يمكن استخدام assertRedirect، لأن عنوان URL لإعادة التوجيه لا يمكن التنبؤ به
        self.assertEqual(response.status_code, 302)
        self.assertTrue(response.url.startswith('/accounts/login/'))

    def test_forbidden_if_logged_in_but_not_correct_permission(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 403)

    def test_logged_in_with_permission_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance2.pk}))

        # تأكد من أنه يتيح لنا تسجيل الدخول، فهذا كتابنا ولدينا الأذونات الصحيحة
        self.assertEqual(response.status_code, 200)

    def test_logged_in_with_permission_another_users_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))

        # تحقق من أنه يتيح لنا تسجيل الدخول، لأننا أمناء مكتبة، ويمكننا عرض جميع كتب المستخدمين
        self.assertEqual(response.status_code, 200)

    def test_HTTP404_for_invalid_book_if_logged_in(self):
        # ‫معرّف uid لمطابقة نسخة كتابنا- غير مرجح حدوثه
        test_uid = uuid.uuid4()
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk':test_uid}))
        self.assertEqual(response.status_code, 404)

    def test_uses_correct_template(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 200)

        # تحقق من أننا نستخدم القالب الصحيح
        self.assertTemplateUsed(response, 'catalog/book_renew_librarian.html')

أضِف تابع الاختبار التالي الذي يتحقق من أن التاريخ الأولي للاستمارة هو ثلاثة أسابيع في المستقبل، ولاحظ كيف أننا قادرون على الوصول إلى القيمة الأولية لحقل الاستمارة response.context['form'].initial['renewal_date']‎:

   def test_form_renewal_date_initially_has_date_three_weeks_in_future(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 200)

        date_3_weeks_in_future = datetime.date.today() + datetime.timedelta(weeks=3)
        self.assertEqual(response.context['form'].initial['renewal_date'], date_3_weeks_in_future)

يتحقق الاختبار التالي (أضفه إلى الصنف أيضًا) من أن العرض يعيد توجيهك إلى قائمة جميع الكتب المستعارة إذا نجح التجديد، والاختلاف هنا هو أننا لأول مرة نعرض كيفية إرسال البيانات POST باستخدام العميل، بحيث تكون هذه البيانات هي الوسيط الثاني للدالة post، وتُحدَّد بوصفها قاموسًا من أزواج مفتاح/قيمة.

   def test_redirects_to_all_borrowed_book_list_on_success(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        valid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=2)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future})
        self.assertRedirects(response, reverse('all-borrowed'))

تحذير: أُضيف العرض all-borrowed كتحدٍ لك، ويمكن أن تعيد شيفرتك البرمجية التوجيه إلى الصفحة الرئيسية '/' بدلًا من ذلك، فإذا كان الأمر كذلك، فعدّل آخر سطرين من شيفرة الاختبار لتكون مماثلة للشيفرة البرمجية التالية، إذ يضمن الوسيط follow=True في الطلب أن يعيد الطلب عنوان URL للهدف النهائي وعندها يجب تحديد /catalog/ بدلًا من /:

 response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future}, follow=True)
 self.assertRedirects(response, '/catalog/')

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

   def test_form_invalid_renewal_date_past(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        date_in_past = datetime.date.today() - datetime.timedelta(weeks=1)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': date_in_past})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response, 'form', 'renewal_date', 'Invalid date - renewal in past')

    def test_form_invalid_renewal_date_future(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        invalid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=5)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': invalid_date_in_future})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response, 'form', 'renewal_date', 'Invalid date - renewal more than 4 weeks ahead')

يمكن استخدام الأساليب نفسها لاختبار العرض الآخر.

القوالب

يوفر جانغو واجهات برمجة التطبيقات API خاصة بالاختبار للتحقق من أن عرضك يستدعي القالب الصحيح، وللسماح بالتحقق من إرسال المعلومات الصحيحة، ولكن لا يوجد دعم محدد لواجهة برمجة التطبيقات للاختبار في جانغو بحيث يُظهر خرج صفحة HTML كما هو متوقع.

أدوات الاختبار الأخرى الموصى بها

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

هناك العديد من أدوات الاختبار الأخرى التي يمكنك استخدامها، ولكننا سنذكر أداتين هما:

  • Coverage: تعطي هذه الأداة الخاصة ببايثون تقريرًا بمقدار شيفرتك البرمجية التي تنّفذها اختباراتك، وهي مفيدة خاصةً عندما تبدأ وتحاول تحديد ما يجب عليك اختباره بالضبط.
  • إطار عمل سيلينيوم Selenium: وهو إطار عمل لأتمتة الاختبار في متصفح حقيقي، ويسمح بمحاكاة تفاعل مستخدم حقيقي مع الموقع، ويوفر إطار عمل رائع لنظام اختبار موقعك (الخطوة التالية من اختبار التكامل).

تحدى نفسك

هناك الكثير من النماذج والعروض التي يمكننا اختبارها، لذا حاول إنشاء حالة اختبار للعرض AuthorCreate:

class AuthorCreate(PermissionRequiredMixin, CreateView):
    model = Author
    fields = '__all__'
    initial = {'date_of_death':'12/10/2016'}
    permission_required = 'catalog.can_mark_returned'

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

الخلاصة

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

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

سنوضح في المقال التالي كيفية نشر موقع جانغو الذي اختبَرناه بالكامل.

ترجمة -وبتصرُّف- للمقال Django Tutorial Part 10: Testing a Django web application.

اقرأ المزيد


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...