لماذا اختبارات الوحدة وكيف تجعلها تعمل لصالحك

إليك رابطًا لفيديو أتحدث فيه عن هذا الموضوع

وإذا لم تكن من محبي الفيديو، فإليك نسخة مكتوبة ومطوّلة عن الموضوع.

البرمجيات

وعد البرمجيات هو أنها قابلة للتغيير. ولهذا تُسمى soft ware؛ فهي طيّعة مقارنة بالعتاد (hardware). وينبغي أن يكون فريق الهندسة العظيم أصلًا مذهلًا للشركة، إذ يكتب أنظمة تستطيع أن تتطور مع الأعمال لتواصل تقديم القيمة.

فلماذا نحن بهذا السوء في ذلك؟ كم مشروعًا تسمع عنه يفشل فشلًا تامًا؟ أو يصبح “قديمًا” (legacy) ولا بد من إعادة كتابته بالكامل (وكثيرًا ما تفشل إعادة الكتابة أيضًا!)

وكيف “يفشل” نظام برمجي أصلًا؟ ألا يمكن ببساطة تغييره حتى يصبح صحيحًا؟ هذا ما وُعدنا به!

كثير من الناس يختارون Go لبناء أنظمتهم لأنها اتخذت عددًا من القرارات التي نأمل أن تجعلها أكثر مناعة ضد التقادم.

  • مقارنةً بحياتي السابقة مع Scala، حيث وصفت كيف تملك من الحبل ما يكفي لشنق نفسك، فإن Go تملك 25 كلمة مفتاحية فقط، ويمكن بناء الكثير من الأنظمة من المكتبة القياسية وعدد قليل من المكتبات الصغيرة الأخرى. والأمل أنك تستطيع مع Go كتابة كود ثم العودة إليه بعد ستة أشهر ليظل مفهومًا.
  • والأدوات المرتبطة بالاختبار وقياس الأداء (benchmark) والفحص (linting) والإصدار (shipping) من الدرجة الأولى مقارنةً بمعظم البدائل.
  • والمكتبة القياسية رائعة.
  • سرعة ترجمة فائقة تحقق حلقات تغذية راجعة سريعة.
  • وعد Go بالتوافق مع الإصدارات السابقة. يبدو أن Go ستحصل على generics وميزات أخرى في المستقبل، لكن المصممين وعدوا بأن كود Go الذي كتبته قبل خمس سنوات سيظل قابلًا للبناء. لقد أمضيت فعلًا أسابيع في ترقية مشروع من Scala 2.8 إلى 2.10.

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

وفي عام 1974 كتب مهندس برمجيات ذكي اسمه Manny Lehman قوانين Lehman لتطور البرمجيات.

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

وتبدو هذه القوى أشياء مهمة لفهمها إن كان لدينا أي أمل في ألا نبقى في دورة لا تنتهي من إصدار أنظمة تتحول إلى قديمة ثم تُعاد كتابتها مرة بعد مرة.

قانون التغيير المستمر

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

يبدو بديهيًا أن النظام لا بد أن يتغير وإلا صار أقل فائدة، لكن كم مرة يُتجاهل هذا؟

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

غالبًا ما ينشغل الناس بمحاولة اختيار إطار عمل يساعدهم على “التسليم بسرعة”، دون التركيز على عمر النظام من حيث حاجته إلى التطور.

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

كان Lehman في أوج نشاطه في السبعينيات، فقد أعطانا قانونًا آخر نتفكّر فيه.

قانون التعقيد المتزايد

مع تطور النظام يزداد تعقيده، إلا إذا بُذل جهد لتقليله.

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

علينا أن نستمر في إدارة تعقيد النظام مع تغيّر معرفتنا بمجال المشكلة.

إعادة الهيكلة

توجد جوانب كثيرة في هندسة البرمجيات تُبقي البرمجية طيّعة، مثل:

  • تمكين المطورين
  • الكود “الجيد” عمومًا. الفصل السليم بين الاهتمامات، وهكذا
  • مهارات التواصل
  • المعمارية
  • قابلية المراقبة (observability)
  • قابلية النشر (deployability)
  • الاختبارات الآلية
  • حلقات التغذية الراجعة

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

من أين جاءت هذه العبارة؟ وبماذا تختلف إعادة الهيكلة أصلًا عن كتابة الكود؟

أعرف أنني وكثيرين غيري ظنّنا أننا نمارس إعادة الهيكلة، لكننا كنا مخطئين.

يشرح Martin Fowler كيف يخطئ الناس في ذلك

ومع ذلك، كثيرًا ما يُستخدم مصطلح “إعادة الهيكلة” في غير موضعه. فإذا تحدث شخص عن نظام معطّل بضعة أيام بينما يعيد هيكلته، فيمكنك أن تكون شبه متأكد أنه لا يمارس إعادة الهيكلة.

فما هي إذن؟

التحليل إلى عوامل

عندما تعلمت الرياضيات في المدرسة، درست على الأرجح التحليل إلى عوامل. وإليك مثال بسيط جدًا.

احسب 1/2 + 1/4

لفعل ذلك، تحلّل المقامات إلى عوامل، فيتحول التعبير إلى

2/4 + 1/4 ثم يمكنك تحويله إلى 3/4.

يمكننا أن نستخلص من هذا دروسًا مهمة. فعندما نحلّل التعبير إلى عوامل، نكون لم نغيّر معنى التعبير. فكلاهما يساوي 3/4، لكننا جعلنا التعامل معه أسهل؛ فبتغيير 1/2 إلى 2/4 صار يندرج في “مجالنا” بسهولة أكبر.

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

مثال في Go

هذه دالة تحيّي name بلغة معينة language

func Hello(name, language string) string {

  if language == "es" {
     return "Hola, " + name
  }

  if language == "fr" {
     return "Bonjour, " + name
  }
  
  // imagine dozens more languages

  return "Hello, " + name
}

وجود عشرات جمل if لا يبدو جيدًا، ولدينا تكرار في دمج التحية الخاصة بكل لغة مع , ومع name.. لذا سأعيد هيكلة الكود.

func Hello(name, language string) string {
  	return fmt.Sprintf(
  		"%s, %s",
  		greeting(language),
  		name,
  	)
}

var greetings = map[string]string {
  "es": "Hola",
  "fr": "Bonjour",
  //etc..
}

func greeting(language string) string {
  greeting, exists := greetings[language]
  
  if exists {
     return greeting
  }
  
  return "Hello"
}

طبيعة إعادة الهيكلة هذه ليست مهمة في الحقيقة؛ المهم أنني لم أغيّر السلوك.

عند إعادة الهيكلة يمكنك أن تفعل ما يحلو لك: أضف واجهات (interfaces)، وأنواعًا جديدة، ودوالًا، وmethods، وما إلى ذلك. والقاعدة الوحيدة ألا تغيّر السلوك

عند إعادة هيكلة الكود يجب ألا تغيّر السلوك

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

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

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

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

لكي تعيد الهيكلة بأمان، تحتاج إلى اختبارات وحدة لأنها توفّر

  • الثقة بأنك تستطيع إعادة تشكيل الكود دون القلق من تغيير السلوك
  • توثيقًا للبشر عن كيفية تصرف النظام
  • تغذية راجعة أسرع كثيرًا وأكثر موثوقية من الاختبار اليدوي

مثال في Go

قد يبدو اختبار الوحدة لدالة Hello هكذا

func TestHello(t *testing.T) {
  got := Hello(“Chris”, es)
  want := "Hola, Chris"

  if got != want {
     t.Errorf("got %q want %q", got, want)
  }
}

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

تريد أن تصل إلى حالة تكون فيها

  • إعادة هيكلة صغيرة
  • تشغيل الاختبارات
  • التكرار

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

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

إذا كانت اختبارات الوحدة بهذه الروعة، فلماذا توجد أحيانًا مقاومة لكتابتها؟

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

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

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

وهذا عكس ما وُعدنا به تمامًا!

لماذا يحدث هذا؟

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

مثلثان قائما الزاوية لتكوين مربع

نكتب اختبارات الوحدة حول مربعنا للتأكد أن أضلاعه متساوية، ثم نكتب بعض الاختبارات حول مثلثاتنا. نريد التأكد أن مثلثاتنا تُرسم بشكل صحيح، فنؤكد أن مجموع الزوايا 180 درجة، وربما نتحقق أننا نصنع مثلثين منها، وهكذا. تغطية الاختبارات مهمة حقًا، وكتابة هذه الاختبارات سهلة جدًا، فلمَ لا؟

بعد بضعة أسابيع، يضرب قانون التغيير المستمر نظامنا، ويجري مطور جديد بعض التغييرات. وترى الآن أنه سيكون أفضل لو تكوّن المربع من مستطيلين بدلًا من مثلثين.

مستطيلان لتكوين مربع

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

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

فضّل اختبار السلوك على تفاصيل التنفيذ

عندما أسمع أشخاصًا يشتكون من اختبارات الوحدة، فغالبًا لأن الاختبارات في مستوى تجريد خاطئ. فهي تختبر تفاصيل التنفيذ، وتتجسس أكثر من اللازم على المتعاونين (collaborators)، وتفرط في استخدام الـ mocking.

وأعتقد أن مصدر ذلك سوء فهم لما تكون عليه اختبارات الوحدة، والسعي وراء مقاييس مغرية للمظهر (vanity metrics) مثل التغطية.

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

فما مستوى التجريد الصحيح إذن؟

كتابة اختبارات وحدة فعّالة مسألة تصميم

لننسَ الاختبارات لحظة؛ من المرغوب أن تكون داخل نظامك “وحدات” (units) قائمة بذاتها ومفصولة عن غيرها، تتمحور حول مفاهيم أساسية في مجالك.

أحب أن أتخيل هذه الوحدات كقطع ليغو بسيطة لها واجهات برمجة (APIs) متماسكة أستطيع دمجها مع قطع أخرى لبناء أنظمة أكبر. وتحت هذه الواجهات قد توجد عشرات الأشياء (أنواع ودوال وغيرها) تتعاون لتجعلها تعمل كما ينبغي.

مثلًا، لو كنت تكتب بنكًا بلغة Go، فقد تكون لديك حزمة “account”. وستقدّم واجهة برمجة لا تسرّب تفاصيل التنفيذ ويسهل الدمج معها.

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

هل هذه اختبارات وحدة؟

نعم. فاختبارات الوحدة تكون مقابل “وحدات” كما وصفت. ولم يكن الأمر يومًا مقتصرًا على أن تكون مقابل صنف واحد أو دالة واحدة أو ما شابه.

جمع هذه المفاهيم معًا

لقد غطّينا

  • إعادة الهيكلة
  • اختبارات الوحدة
  • تصميم الوحدات

وما نبدأ في رؤيته هو أن هذه الجوانب من تصميم البرمجيات تعزز بعضها بعضًا.

إعادة الهيكلة

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

اختبارات الوحدة

  • تمنحنا شبكة أمان لإعادة الهيكلة.
  • تتحقق من سلوك وحداتنا وتوثّقه.

الوحدات (المصممة جيدًا)

  • يسهل كتابة اختبارات وحدة ذات معنى لها.
  • يسهل إعادة هيكلتها.

هل توجد عملية تساعدنا على الوصول إلى نقطة نعيد فيها هيكلة كودنا باستمرار لإدارة التعقيد وإبقاء أنظمتنا طيّعة؟

لماذا التطوير الموجه بالاختبار (TDD)

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

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

أقول “الأيام القديمة السيئة”، لكن هذا ما زال يحدث!

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

يعالج TDD القوانين التي يتحدث عنها Lehman ودروسًا أخرى تعلمناها بالصعوبة عبر التاريخ، بتشجيعه على منهجية تقوم على إعادة الهيكلة المستمرة والتسليم التكراري.

خطوات صغيرة

  • اكتب اختبارًا صغيرًا لقدر صغير من السلوك المطلوب
  • تحقق أن الاختبار يفشل برسالة خطأ واضحة (أحمر)
  • اكتب أصغر قدر من الكود لينجح الاختبار (أخضر)
  • أعد الهيكلة
  • كرّر

ومع إتقانك، ستصبح طريقة العمل هذه طبيعية وسريعة.

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

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

الخلاصة

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