اختبارات الوحدة ، وكيفية كتابة التعليمات البرمجية القابلة للاختبار وسبب أهميتها
نشرت: 2022-03-11اختبار الوحدة هو أداة أساسية في صندوق الأدوات لأي مطور برامج جاد. ومع ذلك ، قد يكون من الصعب في بعض الأحيان كتابة اختبار وحدة جيد لجزء معين من التعليمات البرمجية. نظرًا لصعوبة اختبار التعليمات البرمجية الخاصة بهم أو الخاصة بشخص آخر ، غالبًا ما يعتقد المطورون أن معاناتهم ناتجة عن نقص بعض المعرفة الأساسية للاختبار أو تقنيات اختبار الوحدة السرية.
في هذا البرنامج التعليمي الخاص باختبار الوحدة ، أعتزم إثبات أن اختبارات الوحدة سهلة للغاية ؛ المشاكل الحقيقية التي تعقد اختبار الوحدة ، وتؤدي إلى تعقيد باهظ الثمن ، هي نتيجة لسوء التصميم ، وعدم قابلية اختبار الكود. سنناقش ما الذي يجعل من الصعب اختبار الكود ، وما هي الأنماط المضادة والممارسات السيئة التي يجب أن نتجنبها لتحسين قابلية الاختبار ، وما هي الفوائد الأخرى التي يمكننا تحقيقها من خلال كتابة تعليمات برمجية قابلة للاختبار. سنرى أن كتابة اختبارات الوحدة وإنشاء كود قابل للاختبار لا يقتصران فقط على جعل الاختبار أقل إزعاجًا ، بل يتعلق أيضًا بجعل الكود نفسه أكثر قوة ، ويسهل صيانته.
ما هو اختبار الوحدة؟
بشكل أساسي ، اختبار الوحدة هو طريقة تقوم بإنشاء مثيل لجزء صغير من تطبيقنا والتحقق من سلوكه بشكل مستقل عن الأجزاء الأخرى . يحتوي اختبار الوحدة النموذجي على 3 مراحل: أولاً ، يقوم بتهيئة جزء صغير من التطبيق الذي يريد اختباره (المعروف أيضًا باسم النظام قيد الاختبار ، أو SUT) ، ثم يطبق بعض التحفيز على النظام قيد الاختبار (عادةً عن طريق استدعاء a الطريقة على ذلك) ، وأخيراً ، يلاحظ السلوك الناتج. إذا كان السلوك المرصود متوافقًا مع التوقعات ، فإن اختبار الوحدة يجتاز ، وإلا فإنه يفشل ، مما يشير إلى وجود مشكلة في مكان ما في النظام قيد الاختبار. تُعرف مراحل اختبار الوحدات الثلاث هذه أيضًا باسم Arrange و Act و Assert أو ببساطة AAA.
يمكن لاختبار الوحدة التحقق من الجوانب السلوكية المختلفة للنظام قيد الاختبار ، ولكن على الأرجح ستندرج في إحدى الفئتين التاليتين: قائم على الحالة أو قائم على التفاعل . يُطلق على التحقق من أن النظام قيد الاختبار ينتج نتائج صحيحة ، أو أن حالته الناتجة صحيحة ، اختبار الوحدة المستند إلى الحالة ، بينما يُسمى التحقق من أنه يستدعي بشكل صحيح طرقًا معينة اختبار الوحدة القائم على التفاعل .
كاستعارة لاختبار وحدة البرامج المناسبة ، تخيل عالمًا مجنونًا يريد بناء بعض الوهم الخارق للطبيعة ، بأرجل الضفادع ، ومخالب الأخطبوط ، وأجنحة الطيور ، ورأس الكلب. (هذه الاستعارة قريبة جدًا مما يفعله المبرمجون في الواقع في العمل). كيف سيتأكد هذا العالم من أن كل جزء (أو وحدة) اختارها تعمل بالفعل؟ حسنًا ، يمكنه أن يأخذ ، لنقل ، ساق ضفدعة واحدة ، ويطبق محفزًا كهربائيًا عليها ، ويتحقق من تقلص العضلات المناسب. ما يفعله هو في الأساس نفس خطوات ترتيب العمل والتأكيد لاختبار الوحدة ؛ الاختلاف الوحيد هو أنه في هذه الحالة ، تشير الوحدة إلى كائن مادي ، وليس إلى كائن مجرد نبني برامجنا منه.
سأستخدم C # لجميع الأمثلة في هذه المقالة ، لكن المفاهيم الموصوفة تنطبق على جميع لغات البرمجة الموجهة للكائنات.
مثال على اختبار وحدة بسيط يمكن أن يبدو كالتالي:
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
اختبار الوحدة مقابل اختبار التكامل
شيء آخر مهم يجب مراعاته هو الفرق بين اختبار الوحدة واختبار التكامل.
الغرض من اختبار الوحدة في هندسة البرمجيات هو التحقق من سلوك جزء صغير نسبيًا من البرنامج ، بشكل مستقل عن الأجزاء الأخرى. اختبارات الوحدة ضيقة النطاق ، وتسمح لنا بتغطية جميع الحالات ، مما يضمن أن كل جزء يعمل بشكل صحيح.
من ناحية أخرى ، تُظهر اختبارات التكامل أن أجزاء مختلفة من النظام تعمل معًا في بيئة الحياة الواقعية . إنهم يتحققون من صحة السيناريوهات المعقدة (يمكننا التفكير في اختبارات التكامل كمستخدم يقوم ببعض العمليات عالية المستوى داخل نظامنا) ، وعادة ما يتطلب وجود موارد خارجية ، مثل قواعد البيانات أو خوادم الويب.
دعنا نعود إلى استعارة العالم المجنون ، ونفترض أنه نجح في الجمع بين جميع أجزاء الوهم. إنه يريد إجراء اختبار تكامل للمخلوق الناتج ، والتأكد من أنه يمكن ، على سبيل المثال ، السير في أنواع مختلفة من التضاريس. بادئ ذي بدء ، يجب على العالم محاكاة بيئة يمشي عليها المخلوق. بعد ذلك ، يرمي المخلوق في تلك البيئة ويضربها بعصا ، ملاحظًا ما إذا كان يمشي ويتحرك حسب التصميم. بعد الانتهاء من الاختبار ، يقوم العالم المجنون بتنظيف كل الأوساخ والرمل والصخور المتناثرة الآن في مختبره الجميل.
لاحظ الاختلاف الكبير بين اختبارات الوحدة والتكامل: يتحقق اختبار الوحدة من سلوك جزء صغير من التطبيق ، معزولًا عن البيئة والأجزاء الأخرى ، ويسهل تنفيذه تمامًا ، بينما يغطي اختبار التكامل التفاعلات بين المكونات المختلفة ، في بيئة قريبة من الواقع ، وتتطلب المزيد من الجهد ، بما في ذلك مراحل الإعداد والتفكيك الإضافية.
تضمن مجموعة معقولة من اختبارات الوحدة والتكامل أن كل وحدة تعمل بشكل صحيح ، بشكل مستقل عن الأخرى ، وأن جميع هذه الوحدات تعمل بشكل جيد عند التكامل ، مما يمنحنا مستوى عاليًا من الثقة في أن النظام بأكمله يعمل كما هو متوقع.
ومع ذلك ، يجب أن نتذكر دائمًا تحديد نوع الاختبار الذي نجريه: اختبار وحدة أم اختبار تكامل. قد يكون الاختلاف خادعًا في بعض الأحيان. إذا كنا نعتقد أننا نكتب اختبار وحدة للتحقق من بعض الحالات الدقيقة الدقيقة في فئة منطق الأعمال ، وندرك أنه يتطلب موارد خارجية مثل خدمات الويب أو قواعد البيانات لتكون موجودة ، هناك شيء غير صحيح - بشكل أساسي ، نحن نستخدم مطرقة ثقيلة كسر الجوز. وهذا يعني تصميم سيء.
ما الذي يجعل اختبار الوحدة جيدًا؟
قبل الغوص في الجزء الرئيسي من هذا البرنامج التعليمي واختبارات وحدة الكتابة ، دعنا نناقش بسرعة خصائص اختبار الوحدة الجيد. تتطلب مبادئ اختبار الوحدة أن يكون الاختبار الجيد هو:
سهل الكتابة. يكتب المطورون عادةً الكثير من اختبارات الوحدة لتغطية الحالات والجوانب المختلفة لسلوك التطبيق ، لذلك يجب أن يكون من السهل ترميز جميع إجراءات الاختبار هذه دون بذل جهد هائل.
مقروء. يجب أن يكون القصد من اختبار الوحدة واضحًا. يروي اختبار الوحدة الجيد قصة عن بعض الجوانب السلوكية لتطبيقنا ، لذلك يجب أن يكون من السهل فهم السيناريو الذي يتم اختباره - وإذا فشل الاختبار - فمن السهل اكتشاف كيفية معالجة المشكلة. من خلال اختبار الوحدة الجيد ، يمكننا إصلاح الخلل دون تصحيح أخطاء الكود فعليًا!
موثوق. يجب ألا تفشل اختبارات الوحدة إلا إذا كان هناك خلل في النظام قيد الاختبار. يبدو هذا واضحًا جدًا ، ولكن غالبًا ما يواجه المبرمجون مشكلة عندما تفشل اختباراتهم حتى في حالة عدم ظهور أخطاء. على سبيل المثال ، قد تمر الاختبارات عند التشغيل واحدًا تلو الآخر ، ولكنها تفشل عند تشغيل مجموعة الاختبار بأكملها ، أو تمر على آلة التطوير الخاصة بنا وتفشل في خادم التكامل المستمر. هذه المواقف تدل على وجود عيب في التصميم. يجب أن تكون اختبارات الوحدة الجيدة قابلة للتكرار ومستقلة عن العوامل الخارجية مثل البيئة أو أمر التشغيل.
بسرعة. يكتب المطورون اختبارات الوحدة حتى يتمكنوا من تشغيلها بشكل متكرر والتحقق من عدم ظهور أي أخطاء. إذا كانت اختبارات الوحدة بطيئة ، فمن المرجح أن يتخطى المطورون تشغيلها على أجهزتهم الخاصة. لن يحدث اختبار بطيء واحد فرقًا كبيرًا ؛ أضف ألفًا أخرى ونحن بالتأكيد عالقون في الانتظار لبعض الوقت. قد تشير اختبارات الوحدة البطيئة أيضًا إلى أن النظام قيد الاختبار ، أو الاختبار نفسه ، يتفاعل مع الأنظمة الخارجية ، مما يجعله معتمداً على البيئة.
حقا وحدة وليس اندماج. كما ناقشنا بالفعل ، فإن اختبارات الوحدة والتكامل لها أغراض مختلفة. يجب ألا يصل كل من اختبار الوحدة والنظام قيد الاختبار إلى موارد الشبكة وقواعد البيانات ونظام الملفات وما إلى ذلك ، للقضاء على تأثير العوامل الخارجية.
هذا كل شيء - لا توجد أسرار لكتابة اختبارات الوحدة . ومع ذلك ، هناك بعض التقنيات التي تسمح لنا بكتابة كود قابل للاختبار .
كود قابل للاختبار وغير قابل للاختبار
تتم كتابة بعض التعليمات البرمجية بطريقة تجعل من الصعب ، أو حتى المستحيل ، كتابة اختبار وحدة جيد لها. إذن ، ما الذي يجعل من الصعب اختبار الكود؟ دعنا نراجع بعض الأنماط المضادة وروائح الشفرات والممارسات السيئة التي يجب أن نتجنبها عند كتابة كود قابل للاختبار.
تسمم قاعدة الكود بالعوامل غير الحتمية
لنبدأ بمثال بسيط. تخيل أننا نكتب برنامجًا لوحدة تحكم دقيقة للمنزل الذكي ، وأحد المتطلبات هو تشغيل الضوء تلقائيًا في الفناء الخلفي إذا تم اكتشاف بعض الحركة هناك أثناء المساء أو في الليل. لقد بدأنا من الأسفل إلى الأعلى بتنفيذ طريقة تعرض سلسلة تمثيل للوقت التقريبي من اليوم ("الليل" أو "الصباح" أو "بعد الظهر" أو "المساء"):
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
بشكل أساسي ، تقرأ هذه الطريقة وقت النظام الحالي وتعيد نتيجة بناءً على تلك القيمة. إذن ، ما الخطأ في هذا الرمز؟
إذا فكرنا في الأمر من منظور اختبار الوحدة ، فسنرى أنه من غير الممكن كتابة اختبار وحدة مناسب قائم على الحالة لهذه الطريقة. DateTime.Now
. الآن ، بشكل أساسي ، إدخال مخفي ، من المحتمل أن يتغير أثناء تنفيذ البرنامج أو بين عمليات التشغيل التجريبية. وبالتالي ، ستؤدي الدعوات اللاحقة إليها إلى نتائج مختلفة.
مثل هذا السلوك غير الحتمي يجعل من المستحيل اختبار المنطق الداخلي GetTimeOfDay()
دون تغيير تاريخ النظام ووقته بالفعل. دعنا نلقي نظرة على كيفية تنفيذ هذا الاختبار:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
مثل هذه الاختبارات من شأنها أن تنتهك الكثير من القواعد التي نوقشت سابقًا. قد تكون الكتابة مكلفة (بسبب الإعداد غير التافه ومنطق التفكيك) ، وغير موثوق بها (قد تفشل حتى إذا لم يكن هناك أخطاء في النظام قيد الاختبار ، بسبب مشكلات إذن النظام ، على سبيل المثال) ، وليست مضمونة اجري بسرعة. وأخيرًا ، لن يكون هذا الاختبار في الواقع اختبارًا للوحدة - سيكون شيئًا بين الوحدة واختبار التكامل ، لأنه يتظاهر باختبار حالة حافة بسيطة ولكنه يتطلب إعداد بيئة بطريقة معينة. النتيجة لا تستحق الجهد ، أليس كذلك؟
تبين أن جميع مشكلات قابلية الاختبار هذه ناتجة عن واجهة برمجة تطبيقات GetTimeOfDay()
منخفضة الجودة. تعاني هذه الطريقة بشكلها الحالي من عدة مشاكل:
إنه مقترن بإحكام بمصدر البيانات الملموس. لا يمكن إعادة استخدام هذه الطريقة لمعالجة التاريخ والوقت المسترجعين من مصادر أخرى ، أو تمريرهما كوسيطة ؛ تعمل الطريقة فقط مع تاريخ ووقت الجهاز المعين الذي ينفذ الكود. اقتران ضيق هو الجذر الأساسي لمعظم مشاكل قابلية الاختبار.
ينتهك مبدأ المسؤولية الفردية (SRP). الطريقة لها مسؤوليات متعددة. يستهلك المعلومات ويعالجها أيضًا. مؤشر آخر على انتهاك SRP هو عندما يكون لفئة أو طريقة واحدة أكثر من سبب للتغيير . من هذا المنظور ، يمكن تغيير أسلوب
GetTimeOfDay()
إما بسبب تعديلات المنطق الداخلي ، أو لأنه يجب تغيير مصدر التاريخ والوقت.إنها تكمن في المعلومات المطلوبة لإنجاز مهمتها. يجب على المطورين قراءة كل سطر من التعليمات البرمجية المصدر الفعلية لفهم ماهية المدخلات المخفية المستخدمة ومن أين أتوا. توقيع الطريقة وحده لا يكفي لفهم سلوك الطريقة.
من الصعب التنبؤ بها والمحافظة عليها. لا يمكن التنبؤ بسلوك الطريقة التي تعتمد على حالة عالمية قابلة للتغيير بمجرد قراءة الكود المصدري ؛ من الضروري مراعاة قيمتها الحالية ، جنبًا إلى جنب مع التسلسل الكامل للأحداث التي كان من الممكن أن تغيرها في وقت سابق. في تطبيق حقيقي ، تصبح محاولة كشف كل هذه الأشياء مشكلة حقيقية.
بعد مراجعة API ، دعنا نصلحها أخيرًا! لحسن الحظ ، هذا أسهل بكثير من مناقشة كل عيوبه - نحتاج فقط إلى كسر المخاوف المترابطة بإحكام.
إصلاح API: تقديم طريقة حجة
الطريقة الأكثر وضوحًا وسهولة لإصلاح واجهة برمجة التطبيقات هي تقديم وسيطة طريقة:
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
تتطلب هذه الطريقة الآن من المتصل تقديم وسيطة DateTime
، بدلاً من البحث سرًا عن هذه المعلومات بمفرده. من منظور اختبار الوحدة ، هذا شيء عظيم ؛ أصبحت الطريقة الآن حتمية (على سبيل المثال ، تعتمد قيمة الإرجاع بشكل كامل على الإدخال) ، لذا فإن الاختبار المستند إلى الحالة يكون سهلاً مثل تمرير بعض قيمة DateTime
والتحقق من النتيجة:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
لاحظ أن هذا المُعدِّل البسيط قد حل أيضًا جميع مشكلات واجهة برمجة التطبيقات التي تمت مناقشتها مسبقًا (الاقتران الضيق ، وانتهاك SRP ، وواجهة برمجة التطبيقات (API) غير الواضحة وصعبة الفهم) من خلال تقديم خط واضح بين البيانات التي يجب معالجتها وكيفية القيام بها.
ممتاز - الطريقة قابلة للاختبار ولكن ماذا عن عملائها ؟ الآن تقع على عاتق المتصل مسؤولية توفير التاريخ والوقت GetTimeOfDay(DateTime dateTime)
، مما يعني أنه قد يصبح غير قابل للاختبار إذا لم نولي اهتمامًا كافيًا. دعونا نلقي نظرة على كيفية التعامل مع ذلك.
إصلاح واجهة برمجة تطبيقات العميل: إدخال التبعية
لنفترض أننا نواصل العمل على نظام المنزل الذكي ، وننفذ العميل التالي GetTimeOfDay(DateTime dateTime)
- رمز متحكم المنزل الذكي المذكور أعلاه والمسؤول عن تشغيل الضوء أو إيقاف تشغيله ، بناءً على الوقت من اليوم واكتشاف الحركة :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
أوتش! لدينا نفس النوع المخفي من مشكلة الإدخال DateTime.Now
- الاختلاف الوحيد هو أنها تقع على مستوى أعلى قليلاً من مستوى التجريد. لحل هذه المشكلة ، يمكننا تقديم حجة أخرى ، ومرة أخرى تفويض مسؤولية توفير قيمة DateTime
إلى المتصل بطريقة جديدة مع ActuateLights(bool motionDetected, DateTime dateTime)
. ولكن ، بدلاً من نقل المشكلة إلى مستوى أعلى في مكدس المكالمات مرة أخرى ، دعنا نستخدم تقنية أخرى تسمح لنا بالحفاظ على كل من ActuateLights(bool motionDetected)
للاختبار: عكس التحكم ، أو IoC.
إن عكس التحكم هو أسلوب بسيط ولكنه مفيد للغاية لفصل الكود ، ولاختبار الوحدة على وجه الخصوص. (بعد كل شيء ، الحفاظ على اقتران الأشياء بشكل غير محكم هو أمر ضروري لتكون قادرًا على تحليلها بشكل مستقل عن بعضها البعض.) النقطة الأساسية في IoC هي فصل كود اتخاذ القرار ( وقت القيام بشيء ما) عن كود الإجراء ( ما يجب فعله عندما يحدث شيء ما ). تزيد هذه التقنية من المرونة ، وتجعل الكود الخاص بنا أكثر نمطية ، ويقلل من الاقتران بين المكونات.
يمكن تنفيذ انعكاس التحكم بعدة طرق ؛ دعنا نلقي نظرة على مثال واحد محدد - حقن التبعية باستخدام المُنشئ - وكيف يمكن أن يساعد في بناء واجهة برمجة تطبيقات SmartHomeController
قابلة للاختبار.
أولاً ، لنقم بإنشاء واجهة IDateTimeProvider
، تحتوي على توقيع أسلوب للحصول على بعض التاريخ والوقت:
public interface IDateTimeProvider { DateTime GetDateTime(); }
بعد ذلك ، اجعل SmartHomeController
يشير إلى تنفيذ IDateTimeProvider
، وقم بتفويضه بمسؤولية الحصول على التاريخ والوقت:
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
الآن يمكننا أن نرى سبب SmartHomeController
Inversion of Control بهذا الاسم: تم عكس التحكم في الآلية التي يجب استخدامها لقراءة التاريخ والوقت ، والآن ينتمي إلى عميل SmartHomeController ، وليس SmartHomeController
نفسه. وبالتالي ، فإن تنفيذ طريقة ActuateLights(bool motionDetected)
يعتمد بشكل كامل على شيئين يمكن إدارتهما بسهولة من الخارج: وسيطة motionDetected
، والتطبيق الملموس لـ IDateTimeProvider
، الذي تم تمريره إلى مُنشئ SmartHomeController
.

لماذا هذا مهم لاختبار الوحدة؟ وهذا يعني أنه يمكن استخدام تطبيقات IDateTimeProvider
المختلفة في كود الإنتاج ورمز اختبار الوحدة. في بيئة الإنتاج ، سيتم إدخال بعض تطبيقات الحياة الواقعية (على سبيل المثال ، تطبيق يقرأ وقت النظام الفعلي). ومع ذلك ، في اختبار الوحدة ، يمكننا إدخال تنفيذ "زائف" يُرجع قيمة DateTime
ثابتة أو محددة مسبقًا مناسبة لاختبار سيناريو معين.
قد يبدو التطبيق المزيف لـ IDateTimeProvider
كما يلي:
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
بمساعدة هذه الفئة ، من الممكن عزل SmartHomeController
عن العوامل غير الحتمية وإجراء اختبار الوحدة على أساس الحالة. دعنا نتحقق من أنه إذا تم اكتشاف حركة ، فسيتم تسجيل وقت تلك الحركة في خاصية LastMotionTime
:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
رائعة! اختبار مثل هذا لم يكن ممكنا قبل إعادة البناء. الآن بعد أن أزلنا العوامل غير الحتمية وتحققنا من السيناريو القائم على الدولة ، هل تعتقد أن SmartHomeController
قابل للاختبار بالكامل؟
تسمم Codebase مع الآثار الجانبية
على الرغم من حقيقة أننا حللنا المشاكل الناتجة عن المدخلات المخفية غير الحتمية ، وتمكنا من اختبار وظائف معينة ، إلا أن الكود (أو على الأقل بعضه) لا يزال غير قابل للاختبار!
دعنا نراجع الجزء التالي من طريقة ActuateLights(bool motionDetected)
المسؤولة عن تشغيل الضوء أو إيقاف تشغيله:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
كما نرى ، يفوض SmartHomeController
مسؤولية تشغيل أو إيقاف تشغيل الضوء إلى كائن BackyardLightSwitcher
، والذي يقوم بتنفيذ نمط Singleton. ما الخطأ في هذا التصميم؟
لاختبار الوحدة الكاملة ActuateLights(bool motionDetected)
، يجب أن نجري اختبارًا قائمًا على التفاعل بالإضافة إلى الاختبار القائم على الحالة ؛ وهذا يعني أننا يجب أن نتأكد من استدعاء طرق تشغيل الضوء أو إيقاف تشغيله إذا ، وفقط في حالة استيفاء الشروط المناسبة. لسوء الحظ ، لا يسمح لنا التصميم الحالي بالقيام بذلك: TurnOn()
و TurnOff()
في BackyardLightSwitcher
إلى إحداث بعض التغييرات في الحالة في النظام ، أو بعبارة أخرى ، إنتاج آثار جانبية . الطريقة الوحيدة للتحقق من استدعاء هذه الأساليب هي التحقق مما إذا كانت الآثار الجانبية المقابلة لها قد حدثت بالفعل أم لا ، والتي قد تكون مؤلمة.
في الواقع ، لنفترض أن مستشعر الحركة وفانوس الفناء الخلفي ووحدة التحكم الدقيقة للمنزل الذكي متصلة بشبكة إنترنت الأشياء وتتواصل باستخدام بعض البروتوكولات اللاسلكية. في هذه الحالة ، يمكن أن يحاول اختبار الوحدة استقبال حركة مرور الشبكة وتحليلها. أو ، إذا كانت مكونات الأجهزة متصلة بسلك ، فيمكن لاختبار الوحدة التحقق مما إذا كان الجهد قد تم تطبيقه على الدائرة الكهربائية المناسبة. أو ، بعد كل شيء ، يمكنه التحقق من تشغيل الضوء أو إيقاف تشغيله بالفعل باستخدام مستشعر إضاءة إضافي.
كما نرى ، قد يكون اختبار الوحدة لطرق الآثار الجانبية صعبًا مثل اختبار الوحدة للوحدات غير الحتمية ، وقد يكون مستحيلًا. ستؤدي أي محاولة إلى مشاكل مشابهة لتلك التي رأيناها بالفعل. سيكون الاختبار الناتج صعب التنفيذ ، وغير موثوق به ، ومن المحتمل أن يكون بطيئًا ، وليس حقًا وحدة. وبعد كل ذلك ، فإن وميض الضوء في كل مرة نقوم فيها بتشغيل مجموعة الاختبار سوف يدفعنا في النهاية إلى الجنون!
مرة أخرى ، كل مشاكل قابلية الاختبار هذه ناتجة عن واجهة برمجة التطبيقات السيئة ، وليس قدرة المطور على كتابة اختبارات الوحدة. بغض النظر عن كيفية تنفيذ التحكم في الضوء بالضبط ، فإن واجهة برمجة تطبيقات SmartHomeController
API تعاني من هذه المشكلات المألوفة بالفعل:
إنه مرتبط بإحكام بالتنفيذ الملموس. تعتمد واجهة برمجة التطبيقات على المثيل الملموس المشفر لـ
BackyardLightSwitcher
. لا يمكن إعادة استخدام طريقةActuateLights(bool motionDetected)
لتبديل أي ضوء بخلاف الضوء الموجود في الفناء الخلفي.ينتهك مبدأ المسؤولية الفردية. واجهة برمجة التطبيقات (API) لها سببان للتغيير: أولاً ، التغييرات في المنطق الداخلي (مثل اختيار تشغيل الضوء فقط في الليل ، ولكن ليس في المساء) والثاني ، إذا تم استبدال آلية تبديل الضوء بآخر.
إنها تكمن في تبعياتها. لا توجد طريقة للمطورين لمعرفة أن
SmartHomeController
يعتمد على مكونBackyardLightSwitcher
المشفر ، بخلاف الحفر في الكود المصدري.من الصعب فهمها والحفاظ عليها. ماذا لو رفض الضوء أن يضاء عندما تكون الظروف مناسبة؟ يمكننا قضاء الكثير من الوقت في محاولة إصلاح
SmartHomeController
دون جدوى ، فقط لإدراك أن المشكلة نتجت عن خطأ فيBackyardLightSwitcher
(أو حتى أكثر تسلية ، مصباح محترق!).
ليس من المستغرب أن يكمن حل كل من قابلية الاختبار وقضايا واجهة برمجة التطبيقات منخفضة الجودة في فصل المكونات المترابطة بإحكام عن بعضها البعض. كما هو الحال مع المثال السابق ، فإن استخدام حقن التبعية من شأنه أن يحل هذه المشكلات ؛ ما عليك سوى إضافة تبعية ILightSwitcher
إلى SmartHomeController
، وتفويضها بمسؤولية قلب مفتاح الضوء ، واجتياز تطبيق ILightSwitcher
المزيف للاختبار فقط والذي سيسجل ما إذا كان قد تم استدعاء الطرق المناسبة في ظل الظروف المناسبة. ومع ذلك ، بدلاً من استخدام Dependency Injection مرة أخرى ، فلنراجع نهجًا بديلًا مثيرًا للاهتمام لفصل المسؤوليات.
إصلاح API: وظائف ذات ترتيب أعلى
هذا النهج هو خيار في أي لغة موجهة للكائنات تدعم وظائف من الدرجة الأولى . دعنا نستفيد من الميزات الوظيفية لـ C # ونجعل طريقة ActuateLights(bool motionDetected)
تقبل وسيطتين أخريين: زوج من مفوضي Action
، يشيرون إلى الطرق التي يجب استدعاؤها لتشغيل الضوء وإيقافه. سيحول هذا الحل الطريقة إلى دالة ذات ترتيب أعلى :
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
هذا حل ذو نكهة وظيفية أكثر من نهج حقن التبعية الكلاسيكي الموجه للكائنات الذي رأيناه من قبل ؛ ومع ذلك ، فإنه يتيح لنا تحقيق نفس النتيجة برمز أقل ومزيد من التعبيرية من حقن التبعية. لم يعد من الضروري تنفيذ فئة تتوافق مع واجهة لتزويد SmartHomeController
بالوظائف المطلوبة ؛ بدلاً من ذلك ، يمكننا فقط تمرير تعريف دالة. يمكن اعتبار وظائف الرتبة الأعلى طريقة أخرى لتطبيق عكس التحكم.
الآن ، لإجراء اختبار الوحدة القائم على التفاعل للطريقة الناتجة ، يمكننا تمرير إجراءات وهمية يمكن التحقق منها بسهولة:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
أخيرًا ، لقد جعلنا واجهة برمجة تطبيقات SmartHomeController
قابلة للاختبار بالكامل ، ونحن قادرون على إجراء اختبارات الوحدة القائمة على الحالة والقائمة على التفاعل لها. مرة أخرى ، لاحظ أنه بالإضافة إلى قابلية الاختبار المحسنة ، فإن إدخال خط التماس بين عملية اتخاذ القرار وكود الإجراء ساعد في حل مشكلة الاقتران الضيق ، وأدى إلى واجهة برمجة تطبيقات أكثر نظافة وقابلة لإعادة الاستخدام.
الآن ، من أجل تحقيق تغطية اختبار الوحدة الكاملة ، يمكننا ببساطة تنفيذ مجموعة من الاختبارات المتشابهة للتحقق من صحة جميع الحالات الممكنة - ليست مشكلة كبيرة نظرًا لأن اختبارات الوحدة أصبحت الآن سهلة التنفيذ للغاية.
النجاسة وقابلية الاختبار
اللا حتمية غير المنضبط والآثار الجانبية متشابهة في آثارها المدمرة على قاعدة الكود. عند استخدامها بلا مبالاة ، فإنها تؤدي إلى كود خادع ، يصعب فهمه وصيانته ، وإحكام الارتباط ، وغير قابل لإعادة الاستخدام ، وغير قابل للاختبار.
من ناحية أخرى ، فإن الطرق التي تكون حتمية وخالية من الآثار الجانبية أسهل بكثير في الاختبار ، والتفسير ، وإعادة الاستخدام لبناء برامج أكبر. من حيث البرمجة الوظيفية ، تسمى هذه الأساليب وظائف نقية . نادرًا ما يكون لدينا وحدة مشكلة تختبر وظيفة خالصة ؛ كل ما يتعين علينا القيام به هو تمرير بعض الحجج والتحقق من النتيجة للتأكد من صحتها. ما يجعل الكود غير قابل للاختبار حقًا هو العوامل غير النقية المشفرة والتي لا يمكن استبدالها أو تجاوزها أو استخلاصها بطريقة أخرى.
الشوائب سامة: إذا كانت الطريقة Foo()
تعتمد على طريقة غير حتمية أو ذات آثار جانبية Bar()
، فإن Foo()
تصبح غير حتمية أو لها آثار جانبية أيضًا. في النهاية ، قد ينتهي بنا الأمر بتسميم قاعدة الكود بأكملها. قم بضرب كل هذه المشكلات في حجم التطبيق الواقعي المعقد ، وسنجد أنفسنا مثقلًا بقاعدة بيانات يصعب الحفاظ عليها مليئة بالروائح والأنماط المضادة والاعتمادات السرية وجميع أنواع الأشياء القبيحة وغير السارة.
أما النجاسة فلا مفر منها. يجب على أي تطبيق واقعي ، في مرحلة ما ، قراءة الحالة ومعالجتها من خلال التفاعل مع البيئة أو قواعد البيانات أو ملفات التكوين أو خدمات الويب أو الأنظمة الخارجية الأخرى. لذا بدلاً من استهداف القضاء على الشوائب تمامًا ، من الجيد الحد من هذه العوامل ، وتجنب السماح لها بتسميم قاعدة الكود الخاصة بك ، وكسر التبعيات المشفرة قدر الإمكان ، حتى تتمكن من تحليل الأشياء واختبارها بشكل مستقل.
علامات التحذير الشائعة من صعوبة اختبار التعليمات البرمجية
أخيرًا ، دعنا نراجع بعض علامات التحذير الشائعة التي تشير إلى أنه قد يكون من الصعب اختبار الكود الخاص بنا.
الخصائص والحقول الثابتة
يمكن للخصائص والحقول الثابتة أو ، ببساطة ، الحالة العالمية ، تعقيد فهم الكود وقابلية الاختبار ، عن طريق إخفاء المعلومات المطلوبة لطريقة ما لإنجاز وظيفتها ، عن طريق إدخال اللا حتمية ، أو عن طريق تشجيع الاستخدام المكثف للآثار الجانبية. الوظائف التي تقرأ أو تعدل الحالة العالمية المتغيرة هي بطبيعتها نجسة.
على سبيل المثال ، من الصعب التفكير في الكود التالي ، والذي يعتمد على خاصية يمكن الوصول إليها عالميًا:
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
ماذا لو لم يتم استدعاء طريقة HeatWater()
عندما نكون متأكدين من أنها يجب أن تكون كذلك؟ نظرًا لأن أي جزء من التطبيق قد يكون قد غيّر قيمة CostSavingEnabled
، يجب علينا إيجاد وتحليل جميع الأماكن التي تعدل هذه القيمة من أجل معرفة الخطأ. أيضًا ، كما رأينا بالفعل ، ليس من الممكن تعيين بعض الخصائص الثابتة لأغراض الاختبار (على سبيل المثال ، DateTime.Now
، أو Environment.MachineName
؛ فهي للقراءة فقط ، لكنها لا تزال غير حتمية).
من ناحية أخرى ، فإن الدولة العالمية الثابتة والحتمية لا بأس بها تمامًا. في الواقع ، هناك اسم مألوف أكثر لهذا - ثابت. القيم الثابتة مثل Math.PI
لا تقدم أي عدم حتمية ، وبما أن قيمها لا يمكن تغييرها ، فلا تسمح بأي آثار جانبية:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
الفردي
في الأساس ، يعد نمط Singleton مجرد شكل آخر من أشكال الدولة العالمية. تعزز Singletons واجهات برمجة التطبيقات (API) الغامضة التي تكذب حول التبعيات الحقيقية وتقدم اقترانًا وثيقًا غير ضروري بين المكونات. كما أنهم ينتهكون مبدأ المسؤولية الفردية لأنهم ، بالإضافة إلى واجباتهم الأساسية ، يتحكمون في التهيئة ودورة الحياة الخاصة بهم.
يمكن أن تجعل Singletons اختبارات الوحدة تعتمد على الأمر بسهولة لأنها تحمل حالة طوال عمر التطبيق بأكمله أو مجموعة اختبار الوحدة. ألق نظرة على المثال التالي:
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. علي سبيل المثال:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.