مقدمة للسخرية في بايثون

نشرت: 2022-03-11

كيفية إجراء اختبارات الوحدة في بايثون دون اختبار صبرك

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

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

تتضمن مكتبة Python unittest حزمة فرعية تسمى unittest.mock —أو إذا أعلنتها على أنها تبعية ، فهي ببساطة mock — والتي توفر وسائل قوية ومفيدة للغاية يمكن من خلالها السخرية من هذه الآثار الجانبية غير المرغوب فيها وإيقافها.

السخرية واختبارات الوحدة في مكتبة Python unittest

ملاحظة: تم تضمين mock حديثًا في المكتبة القياسية اعتبارًا من Python 3.3 ؛ يجب أن تستخدم التوزيعات السابقة مكتبة Mock التي يمكن تنزيلها عبر PyPI.

مكالمات النظام مقابل السخرية من Python

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

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

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

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

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

وظيفة حذف بسيطة

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

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)

من الواضح أن طريقة rm الخاصة بنا في هذا الوقت لا توفر أكثر بكثير من طريقة os.remove الأساسية ، لكن قاعدة الكود لدينا ستتحسن ، مما يسمح لنا بإضافة المزيد من الوظائف هنا.

لنكتب حالة اختبار تقليدية ، أي بدون نماذج وهمية:

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")

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

إعادة الهيكلة باستخدام Python Mocks

دعنا نعيد تشكيل حالة الاختبار الخاصة بنا باستخدام mock :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")

مع هذه المعاد تصنيعها ، قمنا بتغيير جذري في الطريقة التي يعمل بها الاختبار. الآن ، لدينا عنصر داخلي ، كائن يمكننا استخدامه للتحقق من وظائف شخص آخر.

المزالق المحتملة لبايثون السخرية

من أول الأشياء التي يجب أن نلاحظها هي أننا نستخدم مصمم طريقة mock.patch للسخرية من كائن موجود في mymodule.os ، وحقن هذا النموذج في طريقة حالة الاختبار الخاصة بنا. ألن يكون من المنطقي مجرد محاكاة os نفسه ، بدلاً من الإشارة إليه في mymodule.os ؟

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

المانترا التي يجب الاستمرار في تكرارها هي:

محاكاة عنصر حيث يتم استخدامه ، وليس من أين أتى.

إذا كنت بحاجة إلى محاكاة الوحدة النمطية لملف myproject.app.MyElaborateClass tempfile فربما تحتاج إلى تطبيق النموذج الوهمي على myproject.app.tempfile ، حيث تحتفظ كل وحدة بوارداتها الخاصة.

مع هذا المأزق بعيدًا عن الطريق ، دعونا نستمر في السخرية.

إضافة التحقق من الصحة إلى "rm"

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

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)

رائعة. الآن ، دعنا نضبط حالة الاختبار الخاصة بنا للحفاظ على التغطية.

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")

لقد تغير نموذج الاختبار لدينا تمامًا. يمكننا الآن التحقق من الوظائف الداخلية للطرق والتحقق منها دون أي آثار جانبية.

إزالة الملفات كخدمة مع التصحيح الوهمي

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

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

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)

ستلاحظ أنه لم يتغير الكثير في حالة الاختبار الخاصة بنا:

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")

رائع ، لذلك نحن نعلم الآن أن خدمة RemovalService تعمل كما هو مخطط لها. لنقم بإنشاء خدمة أخرى تعلن أنها تابعة:

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)

نظرًا لأن لدينا بالفعل تغطية اختبارية على RemovalService ، فإننا لن نتحقق من صحة الوظائف الداخلية لطريقة rm في اختباراتنا لـ UploadService . بدلاً من ذلك ، سنختبر ببساطة (بدون آثار جانبية ، بالطبع) أن UploadService تستدعي طريقة RemovalService.rm ، والتي نعرف أنها "تعمل فقط" من حالة الاختبار السابقة.

هناك طريقتان للذهاب حول هذا الموضوع:

  1. اسخر من طريقة RemovalService.rm نفسها.
  2. قم بتوفير مثيل تم الاستهزاء به في مُنشئ UploadService .

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

الخيار 1: طرق المثيلات الساخرة

تحتوي مكتبة الصور على أداة تزيين خاصة للطريقة mock من طرق وخصائص مثيل الكائن ، مصمم @mock.patch.object :

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")

رائعة! لقد تحققنا من أن UploadService تستدعي بنجاح طريقة rm لمثيلنا. هل لاحظت أي شيء مثير للاهتمام هناك؟ حلت آلية الترقيع في الواقع محل طريقة rm لجميع حالات RemovalService في طريقة الاختبار الخاصة بنا. هذا يعني أنه يمكننا في الواقع فحص الحالات بأنفسهم. إذا كنت ترغب في رؤية المزيد ، فحاول وضع نقطة توقف في التعليمات البرمجية الساخرة للحصول على فكرة جيدة عن كيفية عمل آلية الترقيع.

خطأ التصحيح الوهمي: طلب الديكور

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

 @mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass

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

 patch_sys(patch_os(patch_os_path(test_something)))

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

الخيار 2: إنشاء مثيلات وهمية

بدلاً من السخرية من طريقة المثيل المحددة ، يمكننا بدلاً من ذلك توفير مثيل تم الاستهزاء به لـ UploadService باستخدام مُنشئه. أفضل الخيار 1 أعلاه ، لأنه أكثر دقة بكثير ، ولكن هناك العديد من الحالات التي قد يكون فيها الخيار 2 فعالًا أو ضروريًا. دعنا نعيد تشكيل اختبارنا مرة أخرى:

 #!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")

في هذا المثال ، لم نضطر حتى إلى تصحيح أي وظيفة ، فنحن ببساطة ننشئ مواصفات تلقائية لفئة RemovalService ، ثم نقوم بحقن هذا المثيل في UploadService بنا للتحقق من صحة الوظيفة.

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

mock.Mock في فخ: الفصول mock.MagicMock

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

 class Target(object): def apply(value): return value def method(target, value): return target.apply(value)

يمكننا اختبار هذا باستخدام نموذج mock.Mock مثل هذا:

 class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")

يبدو هذا المنطق منطقيًا ، لكن دعنا نعدل طريقة Target.apply لأخذ المزيد من المعلمات:

 class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None

أعد إجراء اختبارك وستجد أنه لا يزال ينجح. هذا لأنه لم يتم إنشاؤه مقابل واجهة برمجة التطبيقات الفعلية الخاصة بك. لهذا السبب يجب عليك دائمًا استخدام طريقة create_autospec autospec مع @patch و @patch.object .

مثال Python Mock: الاستهزاء بمكالمة Facebook API

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

 import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)

إليك حالة الاختبار الخاصة بنا ، والتي تتحقق من قيامنا بنشر الرسالة دون نشر الرسالة فعليًا :

 import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")

كما رأينا حتى الآن ، من السهل حقًا البدء في كتابة اختبارات أكثر ذكاءً باستخدام mock في بايثون.

خاتمة

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