قصة كوب قهوة بارد وثقة مفرطة
قبل عدة سنوات، كنتُ وفريقي نعمل على نظام مالي حساس لإحدى الشركات. يا جماعة الخير، كنا فخورين بأنفسنا جدًا. قضينا أسابيع في كتابة اختبارات الوحدات (Unit Tests)، ووصلنا إلى مبتغانا: مؤشر تغطية الاختبارات (Test Coverage) يُظهر بفخر “100%”. احتفلنا وشعرنا أننا بنينا حصنًا منيعًا لا يمكن لخطأ أن يخترقه. أذكر أنني قلت لمدير المشروع بثقة: “يا خوي، النظام صخرة، فش إشي بفوت عليه”.
بعد إطلاق النظام بأسبوع، وفي صباح يوم هادئ وأنا أحتسي قهوتي، رن جرس الإنذار. تقرير عاجل: بعض الفواتير يتم حساب خصوماتها بشكل خاطئ، ولكن فقط في حالات نادرة جدًا. كيف حدث هذا؟ ألم تكن تغطيتنا 100%؟
بعد ساعات من التنقيب والبحث، وجدنا المشكلة. كانت في سطر واحد، شرط بسيط: if (itemsCount > 5). اختباراتنا كانت تغطي حالة itemsCount = 10 (true) وحالة itemsCount = 3 (false). لقد غطينا السطر، لكننا لم نختبر الحالة الحدّية (edge case) بالضبط عند itemsCount = 5. كان يجب أن يكون الشرط >= وليس >. خطأ بسيط، لكنه كلفنا الكثير من الوقت والمال و… الثقة.
هنا أدركت أن تغطية الاختبارات بنسبة 100% كانت مجرد وهم. كانت تخبرنا أن كل سطر من الكود “تم لمسه” بواسطة اختبار، لكنها لم تخبرنا أبدًا ما إذا كان هذا “اللمس” ذا معنى حقيقي. كانت هذه الحادثة هي التي قادتني إلى عالم “الاختبار الطفري”، السلاح السري الذي يحول اختباراتك من مجرد شبكة واهية إلى درع فولاذي.
ما هي تغطية الاختبارات؟ ولماذا 100% ليست كافية؟
ببساطة، تغطية الاختبارات هي مقياس يوضح لك نسبة الكود المصدري الذي تم تنفيذه أثناء تشغيل مجموعة الاختبارات الخاصة بك. هناك أنواع مختلفة، أشهرها:
- تغطية الأسطر (Line Coverage): هل تم تنفيذ كل سطر من الكود؟
- تغطية الفروع (Branch Coverage): لكل شرط (if, switch)، هل تم اختبار كل النتائج الممكنة (true/false)؟
- تغطية الدوال (Function Coverage): هل تم استدعاء كل دالة في الكود؟
المشكلة، كما اكتشفت بالطريقة الصعبة، أن هذه المقاييس سطحية. إنها تتحقق من “الكمية” وليس “الكيفية”. يمكنك تحقيق تغطية 100% باختبارات ضعيفة جدًا.
مثال على الثقة الزائفة
لنفترض أن لدينا هذه الدالة البسيطة في JavaScript لتحديد ما إذا كان الرقم موجبًا:
function isPositive(number) {
if (number >= 0) { // الخطأ المتعمد هنا، الصفر ليس موجبًا
return true;
} else {
return false;
}
}
والآن، لنكتب اختبار وحدة (Unit Test) لها:
test('isPositive should return true for positive numbers', () => {
expect(isPositive(10)).toBe(true);
});
test('isPositive should return false for negative numbers', () => {
expect(isPositive(-5)).toBe(false);
});
إذا قمت بتشغيل أداة تغطية الاختبارات (مثل Jest’s coverage reporter)، ستحصل على 100% تغطية للفروع والأسطر. كل شيء يبدو مثاليًا! ولكن ماذا يحدث لو مررنا الرقم 0؟ الدالة ستُرجع true بشكل خاطئ. اختباراتنا “المثالية” لم تكشف هذا الخطأ أبدًا.
“تغطية الاختبارات تخبرك ما هي الأجزاء التي اختبرتها، لكنها لا تخبرك بمدى جودة هذا الاختبار.”
البطل المنقذ: ما هو الاختبار الطفري (Mutation Testing)؟
تخيل أن لديك “شيطانًا” صغيرًا ومشاغبًا (أو “مُطفِّرًا”) يعيش داخل الكود الخاص بك. وظيفته هي تغيير الكود بشكل طفيف وخبيث ثم يرى ما إذا كانت اختباراتك ستلاحظ هذا التغيير أم لا. هذا هو جوهر الاختبار الطفري.
العملية تسير على النحو التالي:
- إنشاء الطفرات (Mutants): تأخذ الأداة الكود الأصلي وتنشئ منه نسخًا متعددة، كل نسخة تحتوي على تغيير صغير جدًا (“طفرة”). هذه التغييرات تشمل:
- تغيير المعاملات الرياضية (
+إلى-). - تغيير المعاملات المنطقية (
>إلى>=أو<). - حذف استدعاء دالة معينة (
sendEmail()يتم إزالتها). - تغيير القيم (
trueإلىfalse).
- تغيير المعاملات الرياضية (
- تشغيل الاختبارات: يتم تشغيل مجموعة اختباراتك الكاملة ضد كل “طفرة” من هذه الطفرات.
- تحليل النتائج:
- الطفرة قُتلت (Killed Mutant): إذا فشل اختبار واحد على الأقل، فهذا رائع! يعني أن اختباراتك قوية بما يكفي لاكتشاف هذا التغيير الخبيث. ✅
- الطفرة نجت (Survived Mutant): إذا نجحت كل اختباراتك، فهذه كارثة صغيرة! يعني أن هناك ثغرة في اختباراتك، فهي لم تلاحظ أن الكود قد تغير. ❌
الهدف هو “قتل” أكبر عدد ممكن من الطفرات. النسبة المئوية للطفرات المقتولة تسمى “مؤشر الطفرات” (Mutation Score)، وهو مقياس أكثر دقة لجودة اختباراتك من تغطية الكود التقليدية.
مثال عملي: لنقضي على بعض الطفرات!
دعنا نعد إلى مثال الدالة isPositive. سنستخدم أداة اختبار طفري شهيرة في عالم JavaScript وهي Stryker.
1. الدالة والاختبار الضعيف
الكود كما هو:
// isPositive.js
function isPositive(number) {
if (number >= 0) { // الخطأ هنا
return true;
} else {
return false;
}
}
// isPositive.test.js
test('should handle positive and negative numbers', () => {
expect(isPositive(10)).toBe(true);
expect(isPositive(-10)).toBe(false);
});
تغطية الكود: 100%. كل شيء تمام، أليس كذلك؟
2. تشغيل أداة الاختبار الطفري (Stryker)
بعد تثبيت Stryker وإعداده، نقوم بتشغيله. سيقوم Stryker بإنشاء طفرات. إحدى الطفرات التي سيولدها ستكون في هذا السطر:
if (number >= 0)
سيقوم Stryker بتغييرها إلى:
// الطفرة رقم 1
if (number > 0)
// الطفرة رقم 2
if (number < 0)
// الطفرة رقم 3
if (true) // طفرة خبيثة جدًا
عندما يشغل Stryker اختباراتنا ضد “الطفرة رقم 1” (number > 0)، ماذا سيحدث؟
- اختبار
isPositive(10):10 > 0هو true. الاختبار ينجح. - اختبار
isPositive(-10):-10 > 0هو false. الاختبار ينجح.
كل الاختبارات نجحت! هذا يعني أن “الطفرة رقم 1” قد نجت. سيُظهر لك تقرير Stryker شيئًا كهذا:
----------------|---------------|------------------|
File | % score | # killed / total |
----------------|---------------|------------------|
All files | 50.00 | 1 / 2 |
isPositive.js | 50.00 | 1 / 2 |
----------------|---------------|------------------|
1 mutant survived!
- ഇൻ isPositive.js:2:9
Conditionals
Mutated: >
Original: >=
التقرير يصرخ في وجهنا: “يا عمي، اختباراتك لم تلاحظ أنني غيرت >= إلى >. هناك ضعف في منطقك!”.
3. تحسين الاختبار لقتل الطفرة
الآن، كيف نقتل هذه الطفرة الناجية؟ ببساطة، نضيف حالة اختبار تكشف هذا الفرق. الحالة الحدّية التي تجاهلناها هي 0.
لنُحدّث الاختبار:
// isPositive.test.js
test('should return true for positive numbers', () => {
expect(isPositive(10)).toBe(true);
});
test('should return false for negative numbers', () => {
expect(isPositive(-10)).toBe(false);
});
// الاختبار الجديد القاتل!
test('should return false for zero', () => {
expect(isPositive(0)).toBe(false); // نتوقع false لأن الصفر ليس موجبًا
});
عند تشغيل هذا الاختبار الجديد، سيفشل على الكود الأصلي! لأن isPositive(0) تُرجع true. هذا يجبرنا على تصحيح الدالة الأصلية:
// isPositive.js (النسخة الصحيحة)
function isPositive(number) {
if (number > 0) { // تم التصحيح
return true;
} else {
return false;
}
}
الآن، إذا أعدنا تشغيل Stryker على الكود الصحيح مع مجموعة الاختبارات المحسّنة، سيتم قتل جميع الطفرات، وسنحصل على Mutation Score قريب من 100%. لقد انتقلنا من اختبار “يلمس” الكود إلى اختبار “يفهم” الكود.
نصائح أبو عمر العملية للاختبار الطفري
بعد سنوات من استخدام هذه التقنية، تعلمت بعض الدروس التي أود مشاركتها معكم:
- لا تستهدف 100% من اليوم الأول: تحقيق مؤشر طفرات 100% أمر صعب ومكلف. ابدأ بالأجزاء الأكثر حساسية في نظامك (التعاملات المالية، المصادقة، منطق العمل المعقد). زي ما بنحكي، “حبة حبة”.
- ادمجه في سير العمل بذكاء: تشغيل الاختبار الطفري على كامل المشروع مع كل تعديل (commit) قد يكون بطيئًا جدًا. استراتيجية أفضل هي تشغيله عند طلبات الدمج (Pull Requests) على الكود الجديد فقط، أو تشغيله بشكل دوري (ليلاً مثلاً) على المشروع بأكمله.
- حلل الطفرات الناجية بحكمة: ليست كل طفرة ناجية تعني وجود خطأ. أحيانًا تكون “طفرة مكافئة” (Equivalent Mutant)، أي أن الكود المُعدّل يؤدي نفس وظيفة الكود الأصلي. تعلم كيف تتعرف عليها وتتجاهلها.
- استخدمه كأداة تعليمية: القيمة الحقيقية للاختبار الطفري ليست في الرقم النهائي، بل في التقارير التي يولدها. إنه يعلمك كيف تفكر مثل المهاجم ويجبرك على كتابة اختبارات أكثر ذكاءً وصلابة.
الخلاصة: من الثقة العمياء إلى اليقين المدروس 🙏
رحلتي مع الاختبار الطفري غيرت فلسفتي في البرمجة. لقد علمتني أن الجودة ليست مجرد رقم في تقرير، بل هي عقلية ومنهجية. الانتقال من مطاردة نسبة 100% في تغطية الاختبارات إلى السعي لقتل أكبر عدد من الطفرات هو انتقال من الثقة العمياء إلى اليقين المبني على دليل.
في المرة القادمة التي تشعر فيها بالرضا عن اختباراتك، اسأل نفسك: “هل اختباراتي قوية بما يكفي لتكتشف شيطانًا صغيرًا يعبث بالكود؟”. إذا كان الجواب “لا أعرف”، فقد حان الوقت لتدعو “المُطفِّر” إلى مشروعك. صدقني، ستشكره لاحقًا.