الأنماط المضادة (Anti-patterns)
من وقت لآخر، تحتاج إلى مراجعة تقنياتك في التطوير الموجه بالاختبار (TDD) وتذكير نفسك بالسلوكيات التي ينبغي تجنبها.
عملية TDD بسيطة من حيث المفهوم، لكنك ستجدها وأنت تمارسها تتحدى مهاراتك في التصميم. لا تظن أن هذا يعني أن TDD صعب، فالصعب هو التصميم!
يسرد هذا الفصل عددًا من الأنماط المضادة في TDD والاختبار، وكيفية علاجها.
عدم ممارسة TDD إطلاقًا
بالطبع، من الممكن كتابة برمجيات رائعة دون TDD، لكن كثيرًا من المشكلات التي رأيتها في تصميم الكود وجودة الاختبارات كان سيكون من الصعب جدًا الوصول إليها لو اتُّبع نهج منضبط في TDD.
من نقاط قوة TDD أنه يمنحك عملية رسمية لتجزئة المشكلات، وفهم ما تحاول تحقيقه (الأحمر)، وإنجازه (الأخضر)، ثم التفكير جيدًا في كيفية إحسانه (الأزرق/إعادة الهيكلة).
وبدون ذلك، تصبح العملية غالبًا عشوائية وغير محكمة، وهو ما قد يجعل الهندسة أصعب مما يمكن أن تكون.
سوء فهم قيود خطوة إعادة الهيكلة
حضرت عددًا من ورش العمل وجلسات البرمجة الجماعية (mobbing) أو الثنائية (pairing) حيث ينجح شخص في تمرير اختبار ويدخل مرحلة إعادة الهيكلة. وبعد بعض التفكير، يرى أنه من الجيد تجريد بعض الكود في struct جديد؛ فيصرخ متزمّت ناشئ:
لا يُسمح لك بفعل ذلك! يجب أن تكتب اختبارًا لهذا أولًا، فنحن نمارس TDD!
يبدو أن هذا سوء فهم شائع. يمكنك أن تفعل بالكود ما تشاء عندما تكون الاختبارات ناجحة (green)، والشيء الوحيد الذي لا يُسمح لك به هو إضافة سلوك أو تغييره.
الغرض من هذه الاختبارات أن تمنحك حرية إعادة الهيكلة، والعثور على التجريدات الصحيحة، وجعل الكود أسهل في التغيير والفهم.
وجود اختبارات لا تفشل (أو الاختبارات دائمة الخضرة)
من المدهش كم يتكرر هذا. تبدأ في تصحيح بعض الاختبارات أو تغييرها فتدرك: لا توجد أي سيناريوهات يمكن أن يفشل فيها هذا الاختبار. أو على الأقل، لن يفشل بالطريقة التي يُفترض أن يحمي منها الاختبار.
وهذا شبه مستحيل مع TDD إذا كنت تتبع الخطوة الأولى،
اكتب اختبارًا وشاهده يفشل
وهذا يحدث دائمًا تقريبًا عندما يكتب المطورون الاختبارات بعد كتابة الكود، و/أو يطاردون تغطية الاختبارات بدلًا من إنشاء مجموعة اختبارات مفيدة.
تحققات بلا فائدة
هل عملت يومًا على نظام ما، وكسرت اختبارًا، ثم رأيت هذا؟
false was not equal to true
أنا أعرف أن false لا تساوي true. لكن هذه رسالة غير مفيدة؛ فهي لا تخبرني بما كسرته. وهذا عرض من أعراض عدم اتباع عملية TDD وعدم قراءة رسالة الخطأ عند الفشل.
فلنعد إلى نقطة البداية،
اكتب اختبارًا وشاهده يفشل (ولا تخجل من رسالة الخطأ)
التحقق من تفاصيل غير ذات صلة
مثال ذلك أن تتحقق من كائن معقد، بينما كل ما يهمك فعليًا في الاختبار هو قيمة أحد حقوله.
// not this, now your test is tightly coupled to the whole object
if !cmp.Equal(complexObject, want) {
t.Error("got %+v, want %+v", complexObject, want)
}
// be specific, and loosen the coupling
got := complexObject.fieldYouCareAboutForThisTest
if got != want {
t.Error("got %q, want %q", got, want)
}والتحققات الإضافية لا تجعل اختبارك أصعب قراءة بخلقها “ضجيجًا” في توثيقك فحسب، بل تربط الاختبار دون داعٍ ببيانات لا تهمه. وهذا يعني أنك إن غيّرت حقول كائنك، أو طريقة تصرفها، فقد تواجه مشكلات ترجمة غير متوقعة أو إخفاقات في اختباراتك.
وهذا مثال على عدم اتباع مرحلة الأحمر بصرامة كافية.
- أن تدع تصميمًا موجودًا يؤثر في طريقة كتابتك للاختبار بدلًا من التفكير في السلوك المرغوب
- ألا تعطي رسالة خطأ الاختبار الفاشل قدرًا كافيًا من الاهتمام
تحققات كثيرة داخل سيناريو واحد في اختبارات الوحدة
قد تجعل كثرة التحققات الاختبارات صعبة القراءة ومتعبة في التصحيح عند فشلها.
وهي غالبًا ما تتسلل تدريجيًا، خصوصًا إذا كانت تهيئة الاختبار معقدة لأنك تتحرج من تكرار نفس التهيئة البشعة للتحقق من شيء آخر. وبدلًا من ذلك، ينبغي أن تصلح مشكلات تصميمك التي تجعل التحقق من أشياء جديدة صعبًا.
ومن القواعد المفيدة أن تسعى إلى تحقق واحد لكل اختبار. وفي Go، استفد من الاختبارات الفرعية (subtests) لتفصل بوضوح بين التحققات في المناسبات التي تحتاج فيها إلى ذلك. وهذه أيضًا تقنية مفيدة للفصل بين التحققات على السلوك وتلك على تفاصيل التنفيذ.
وفي اختبارات أخرى قد تكون التهيئة أو زمن التنفيذ فيها قيدًا (مثل اختبار قبول يشغّل متصفح ويب)، عليك أن توازن بين إيجابيات وسلبيات اختبارات أصعب قليلًا في التصحيح وزمن تنفيذ الاختبارات.
عدم الإنصات إلى اختباراتك
يشير Dave Farley في فيديوه “When TDD goes wrong” إلى أن،
يمنحك TDD أسرع تغذية راجعة ممكنة على تصميمك
من تجربتي الخاصة، يحاول كثير من المطورين ممارسة TDD لكنهم غالبًا يتجاهلون الإشارات التي تعود إليهم من عملية TDD. فيظلون عالقين مع أنظمة هشة ومزعجة، ومجموعة اختبارات ضعيفة.
وببساطة، إذا كان اختبار كودك صعبًا، فإن استخدام كودك صعب أيضًا. تعامل مع اختباراتك كأول مستخدم لكودك، وحينها سترى إن كان كودك ممتعًا للعمل معه أم لا.
لقد أكدت على هذا كثيرًا في الكتاب، وسأقوله مرة أخرى: أنصت إلى اختباراتك.
تهيئة مفرطة، والكثير من الـ test doubles، وغير ذلك.
هل نظرت يومًا إلى اختبار فيه 20 أو 50 أو 100 أو 200 سطر من كود التهيئة قبل أن يحدث أي شيء مثير في الاختبار؟ ثم تضطر إلى تغيير الكود والعودة إلى هذه الفوضى وتتمنى لو كنت في مهنة أخرى؟
فما الإشارات هنا؟ أنصت، فالاختبارات المعقدة == كود معقد. فلماذا كودك معقد؟ وهل يجب أن يكون كذلك؟
- عندما يكون لديك الكثير من الـ test doubles في اختباراتك، فهذا يعني أن الكود الذي تختبره له اعتماديات كثيرة، ما يعني أن تصميمك يحتاج إلى عمل.
- إذا كان اختبارك يعتمد على إعداد تفاعلات متنوعة مع الـ mocks، فهذا يعني أن كودك يجري تفاعلات كثيرة مع اعتمادياته. اسأل نفسك إن كان يمكن جعل هذه التفاعلات أبسط.
واجهات مسرّبة
إذا أعلنت واجهة (interface) فيها methods كثيرة، فهذا يشير إلى تجريد مسرّب. فكّر كيف يمكنك تعريف هذا التعاون بمجموعة methods أكثر تكاملًا، وواحدة إن أمكن.
تلوث الواجهات
كما يقول مثل Go: كلما كبرت الواجهة، ضعف التجريد. إذا كشفت واجهة ضخمة لمستخدمي حزمتك، فأنت تجبرهم على إنشاء stub/mock في اختباراتهم يطابق الواجهة البرمجية (API) بأكملها، ويقدم تنفيذًا أيضًا لـ methods لا يستخدمونها (وأحيانًا يكتفون بإطلاق panic منها ليتضح أنها لا ينبغي أن تُستخدم). هذه الحالة نمط مضاد يُعرف بـ تلوث الواجهات، ولهذا السبب تقدم لك المكتبة القياسية واجهات صغيرة للغاية فقط.
وبدلًا من ذلك، ينبغي أن تكشف من حزمتك struct بسيطًا مع تصدير كل الـ methods ذات الصلة فيه، تاركًا لعملاء واجهتك البرمجية حرية إعلان واجهاتهم الخاصة التي تجرّد المجموعة الفرعية من الـ methods التي يحتاجون إليها: مثل go-redis الذي يكشف struct (redis.Client) لعملاء الـ API.
وبوجه عام، لا ينبغي أن تكشف واجهة للعملاء إلا عندما:
- تتكون الواجهة من مجموعة صغيرة ومتماسكة من الدوال.
- تحتاج الواجهة وتنفيذها إلى الفصل (مثل أن يكون للمستخدمين اختيار بين عدة تنفيذات، أو أنهم بحاجة إلى عمل mock لاعتمادية خارجية).
فكّر في أنواع الـ test doubles التي تستخدمها
- الـ Mocks مفيدة أحيانًا، لكنها قوية للغاية وبالتالي يسهل إساءة استخدامها. جرّب أن تفرض على نفسك قيدًا باستخدام الـ stubs بدلًا منها.
- التحقق من تفاصيل التنفيذ باستخدام الـ spies مفيد أحيانًا، لكن حاول تجنبه. وتذكّر أن تفاصيل تنفيذك عادةً ليست مهمة، ولا تريد أن ترتبط اختباراتك بها إن كان ذلك ممكنًا. اسعَ إلى ربط اختباراتك بسلوك مفيد بدل التفاصيل العارضة.
- اقرأ مقالاتي عن تسمية الـ test doubles إن كان تصنيف الـ test doubles غير واضح قليلًا
تجميع الاعتماديات
هذا كود لـ http.HandlerFunc للتعامل مع تسجيلات المستخدمين الجدد في موقع ويب.
type User struct {
// Some user fields
}
type UserStore interface {
CheckEmailExists(email string) (bool, error)
StoreUser(newUser User) error
}
type Emailer interface {
SendEmail(to User, body string, subject string) error
}
func NewRegistrationHandler(userStore UserStore, emailer Emailer) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// extract out the user from the request body (handle error)
// check user exists (handle duplicates, errors)
// store user (handle errors)
// compose and send confirmation email (handle error)
// if we got this far, return 2xx response
}
}في نظرة أولى، من المعقول القول إن التصميم ليس بهذا السوء. فلديه اعتماديتان فقط!
أعد تقييم التصميم بالنظر إلى مسؤوليات المعالج (handler):
- تحليل جسم الطلب (request body) إلى
User:white_check_mark: - استخدام
UserStoreللتحقق إن كان المستخدم موجودًا :question: - استخدام
UserStoreلتخزين المستخدم :question: - إنشاء رسالة بريد إلكتروني :question:
- استخدام
Emailerلإرسال البريد الإلكتروني :question: - إرجاع استجابة HTTP مناسبة، حسب النجاح والأخطاء وغير ذلك :white_check_mark:
ولاختبار هذا الكود، سيكون عليك كتابة اختبارات كثيرة بدرجات متباينة من تهيئة الـ test doubles والـ spies وغير ذلك
- ماذا لو توسعت المتطلبات؟ ترجمات للرسائل الإلكترونية؟ وإرسال تأكيد برسالة SMS أيضًا؟ هل يبدو منطقيًا أن تضطر إلى تغيير معالج HTTP لاستيعاب هذا التغيير؟
- هل يبدو صحيحًا أن تكون القاعدة المهمة “ينبغي أن نرسل بريدًا إلكترونيًا” داخل معالج HTTP؟
- ولماذا تضطر إلى المرور بطقوس إنشاء طلبات HTTP وقراءة الاستجابات للتحقق من هذه القاعدة؟
أنصت إلى اختباراتك. كتابة اختبارات لهذا الكود بأسلوب TDD ينبغي أن تشعرك بعدم الارتياح سريعًا (أو على الأقل أن تزعج المطور الكسول بداخلك). وإذا شعرت أن الأمر مؤلم، فتوقف وفكّر.
وماذا لو كان التصميم هكذا بدلًا من ذلك؟
type UserService interface {
Register(newUser User) error
}
func NewRegistrationHandler(userService UserService) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// parse user
// register user
// check error, send response
}
}- اختبار المعالج بسيط ✅
- والتغييرات في قواعد التسجيل معزولة بعيدًا عن HTTP، لذا اختبارها أبسط أيضًا ✅
انتهاك التغليف (encapsulation)
التغليف مهم جدًا. وهناك سبب لعدم جعلنا كل شيء في الحزمة مُصدَّرًا (أو عامًا). فنحن نريد واجهات برمجية متماسكة بمساحة سطح صغيرة لتفادي الارتباط المحكم.
يُغرى الناس أحيانًا بجعل دالة أو method عامة لاختبار شيء ما. وبفعل ذلك تسيء إلى تصميمك وترسل رسائل مربكة إلى القائمين على صيانة كودك ومستخدميه.
وقد يترتب على ذلك أن يحاول المطورون تصحيح اختبار ما ثم يدركوا في النهاية أن الدالة التي يجري اختبارها لا تُستدعى إلا من الاختبارات. وهذا بوضوح نتيجة فظيعة، ومضيعة للوقت.
وفي Go، اعتبر موقفك الافتراضي عند كتابة الاختبارات هو من منظور مستهلك لحزمتك. ويمكنك جعل ذلك قيدًا عند الترجمة بأن تجعل اختباراتك تعيش في حزمة اختبار مثل package gocoin_test. وإذا فعلت ذلك، فلن يكون لديك وصول إلا إلى الأعضاء المُصدَّرة من الحزمة، فلا يمكن أن ترتبط بتفاصيل التنفيذ.
اختبارات الجدول المعقدة
الاختبارات القائمة على الجدول طريقة رائعة لاختبار عدد من السيناريوهات المختلفة عندما تكون تهيئة الاختبار واحدة، وكل ما تريد تغييره هو المدخلات.
لكن قد تصبح فوضوية في القراءة والفهم عندما تحاول حشر أنواع أخرى من الاختبارات تحت مسمى امتلاك جدول واحد مجيد.
cases := []struct {
X int
Y int
Z int
err error
IsFullMoon bool
IsLeapYear bool
AtWarWithEurasia bool
}{}لا تخف من الخروج عن جدولك وكتابة اختبارات جديدة بدلًا من إضافة حقول وقيم منطقية (boolean) جديدة إلى struct الجدول.
ومن الأمور التي تضعها في الحسبان عند كتابة البرمجيات،
قد تكون إضافة حقل إلى الجدول “مجرد” أمر سهل، لكنها قد تجعل الأمور أبعد ما تكون عن البساطة.
الخلاصة
يمكن عادةً إرجاع معظم مشكلات اختبارات الوحدة إلى:
- مطورين لا يتبعون عملية TDD
- تصميم ضعيف
لذا، تعلّم تصميم البرمجيات الجيد!
والخبر السار أن TDD يمكنه مساعدتك على تحسين مهاراتك في التصميم، لأنه كما قيل في البداية:
الغرض الأساسي من TDD هو تقديم تغذية راجعة على تصميمك. وللمرة المليون، أنصت إلى اختباراتك، فهي تعكس تصميمك إليك.
كن صادقًا بشأن جودة اختباراتك بالإنصات إلى التغذية الراجعة التي تقدمها لك، وستصبح مطورًا أفضل بفضل ذلك.