يا جماعة الخير، السلام عليكم ورحمة الله.
اسمحوا لي أن أبدأ بقصة قصيرة من قلب المعركة، من أيام كنت فيها أقل خبرة وأكثر ثقة في غير محلها. كنا في الفريق نعمل على نظام مالي حساس، وكان تركيزنا الأكبر على جودة الكود. وضعنا قاعدة صارمة: “لا يُقبل أي كود جديد إلا إذا كانت تغطية الاختبارات (Test Coverage) له 100%”. كنا فخورين بأنفسنا، فلوحات الـ CI/CD تضيء بالأخضر، والتقارير تصرخ “100% Covered!”.
في أحد الأيام، أطلقنا ميزة جديدة لحساب عمولات المبيعات. الكود كان بسيطاً، والاختبارات غطّت كل سطر فيه. احتفلنا بإنجاز المهمة قبل موعدها. وبعد يومين، بدأت تصلنا شكاوى من قسم المحاسبة. الأرقام لا تتطابق! بعد ساعات من التنقيب والتحقيق المؤلم، اكتشفنا الكارثة. كانت في سطر بريء كهذا:
// The original buggy code
if (salesAmount > 1000) {
commission = salesAmount * 0.10;
}
الخطأ كان أن الشرط يجب أن يكون >= وليس >. عندما تكون المبيعات 1000 دينار بالضبط، لا يتم احتساب أي عمولة. “لكن كيف؟!” صرخ أحد الزملاء. “الاختبارات تغطي هذا السطر بالكامل!”.
وبالفعل، كان لدينا اختبار يتحقق من مبيعات بقيمة 1500 دينار، وآخر يتحقق من مبيعات بقيمة 500 دينار. كلاهما نجح، وكلاهما “مرّ” على هذا السطر البرمجي، مما أعطانا تغطية 100%. لكن لم يكن لدينا اختبار واحد يتحقق من الحالة الحدية (Edge Case) وهي 1000 بالضبط. كانت اختباراتنا “ضعيفة”، ونسبة الـ 100% كانت مجرد وهم جميل، ثقة زائفة كلفتنا الكثير من الوقت والجهد و”البهادل” من الإدارة.
هذه الحادثة كانت صفعة أيقظتنا. تعلمنا بالطريقة الصعبة أن تغطية الكود تخبرك ما هي الأسطر التي تم تنفيذها، لكنها لا تخبرك شيئاً عن جودة الاختبارات نفسها. ومن هنا، بدأت رحلتنا مع وحش لطيف اسمه “الاختبار الطفري” أو Mutation Testing.
ما هي مشكلة تغطية الكود (Code Coverage)؟
قبل أن نغوص في الحل، دعونا نوضح المشكلة بشكل أكبر. تغطية الكود هي مقياس نسبة مئوية يوضح حجم الكود المصدري الذي تم “تغطيته” أو “تنفيذه” بواسطة مجموعة الاختبارات الخاصة بك. الفكرة بسيطة: إذا لم يتم تنفيذ سطر من الكود أثناء الاختبار، فمن المستحيل أن تعرف ما إذا كان يعمل بشكل صحيح أم لا.
لكن كما رأينا في قصتي، مجرد تنفيذ السطر لا يكفي. انظر إلى هذا المثال البسيط بلغة JavaScript:
مثال على الثقة الزائفة
لنفترض أن لدينا هذه الدالة البسيطة التي تعطي خصماً للطلبات التي تزيد قيمتها عن 100.
function calculateFinalPrice(price) {
if (price > 100) {
return price * 0.9; // 10% discount
}
return price;
}
والآن، لنكتب اختبار وحدة (Unit Test) لها باستخدام مكتبة مثل Jest:
test('should apply discount for price over 100', () => {
expect(calculateFinalPrice(200)).toBe(180);
});
إذا قمت بتشغيل أداة تغطية الكود الآن، ستحصل على نسبة 100%. لماذا؟ لأن اختبارك مرّ على شرط if (وكانت النتيجة true) ونفّذ السطر الذي يليه. لكن ماذا لو كان الكود يحتوي على خطأ خفي؟
function calculateFinalPrice(price) {
// خطأ: يجب أن يكون price وليس 100
if (price > 100) {
return 100 * 0.9; // BUG! Always returns 90
}
return price;
}
اختبارك الحالي expect(calculateFinalPrice(200)).toBe(180) سيفشل ويكشف هذا الخطأ. هذا جيد. لكن ماذا عن هذا الخطأ الآخر؟
function calculateFinalPrice(price) {
// خطأ: يجب أن يكون > وليس !=
if (price != 100) {
return price * 0.9;
}
return price;
}
اختبارك الحالي (الذي يستخدم 200 كمدخل) سينجح، وستبقى التغطية 100%، لكن الكود خاطئ منطقياً! لأنه سيطبق الخصم على كل الأسعار ما عدا 100 بالضبط. هنا تكمن قوة الاختبار الطفري.
الحل يكمن في “الزومبيز”: مقدمة إلى الاختبار الطفري (Mutation Testing)
تخيل أن الكود الخاص بك هو شخص سليم معافى. الآن، تخيل أن هناك فيروساً يقوم بإحداث تغييرات طفيفة وعشوائية في حمضه النووي (DNA)، محولاً إياه إلى “مُتحوّل” أو “طافر” (Mutant). مهمة جهازك المناعي (اختباراتك) هي اكتشاف هذا التغيير والقضاء على النسخة المتحولة.
هذا بالضبط ما يفعله الاختبار الطفري. إنه ليس نوعاً جديداً من الاختبارات التي تكتبها، بل هو “اختبار لاختباراتك”.
إليك العملية خطوة بخطوة:
- التوليد (Generation): تأخذ أداة الاختبار الطفري الكود المصدري الأصلي الخاص بك.
- التحوير (Mutation): تقوم الأداة بإنشاء نسخ متعددة من الكود، وفي كل نسخة تحدث تغييراً بسيطاً جداً. هذه النسخ تسمى “الطفرات” أو “المتحولون” (Mutants).
- تغيير
>إلى>=أو<. - تغيير
+إلى-. - حذف استدعاء دالة معينة (e.g.,
logger.log("message")). - تغيير شرط
if (condition)إلىif (true)أوif (false).
- تغيير
- التنفيذ (Execution): تقوم الأداة بتشغيل مجموعة اختباراتك الكاملة ضد كل “متحوّل” على حدة.
- التقييم (Evaluation):
- ✅ المتحوّل قُتل (Killed Mutant): إذا فشل اختبار واحد على الأقل عند تشغيله ضد نسخة متحولة من الكود، فهذا يعني أن اختباراتك “قتلت” هذا المتحول. هذا شيء ممتاز! إنه يثبت أن اختباراتك قوية بما يكفي لاكتشاف هذا النوع من الأخطاء.
- ❌ المتحوّل نجا (Survived Mutant): إذا نجحت جميع اختباراتك عند تشغيلها ضد نسخة متحولة، فهذا يعني أن المتحول “نجا”. هذا شيء سيء جداً! إنه يكشف عن ثغرة في اختباراتك. اختباراتك ليست حساسة بما يكفي لملاحظة هذا التغيير، الذي يمثل خطأً محتملاً قد يتسلل إلى بيئة الإنتاج.
النتيجة النهائية هي “คะแนน الطفرة” (Mutation Score)، وهو نسبة المتحولين الذين تم قتلهم من إجمالي المتحولين. كلما ارتفعت هذه النسبة، زادت ثقتك في جودة اختباراتك.
لنطبق الأمر عملياً: مثال باستخدام Stryker.js
Stryker هي واحدة من أشهر أدوات الاختبار الطفري، ولها إصدارات لـ JavaScript/TypeScript و C# و Scala. دعنا نستخدمها مع مثالنا السابق.
الخطوة الأولى: الكود المُراد اختباره واختباراته
لدينا الدالة التالية في ملف calculator.js:
function calculateFinalPrice(price, isPremiumMember) {
if (isPremiumMember || price > 100) {
return price * 0.9; // 10% discount
}
return price;
}
module.exports = calculateFinalPrice;
ولدينا ملف الاختبارات calculator.test.js:
const calculateFinalPrice = require('./calculator');
test('should return same price if not eligible for discount', () => {
expect(calculateFinalPrice(50, false)).toBe(50);
});
test('should apply discount for price > 100', () => {
expect(calculateFinalPrice(200, false)).toBe(180);
});
test('should apply discount for premium members regardless of price', () => {
expect(calculateFinalPrice(50, true)).toBe(45);
});
إذا قمنا بتشغيل تغطية الكود (coverage)، سنحصل على 100%. تبدو الأمور رائعة!
الخطوة الثانية: تشغيل الاختبار الطفري
بعد تثبيت وتهيئة Stryker.js في مشروعنا، كل ما علينا فعله هو تشغيل الأمر:
npx stryker run
بعد بضع لحظات، سنحصل على تقرير مفصل. قد يبدو جزء منه كالتالي:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
mutants.
/calculator.js
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1. [Survived] ConditionalExpression
~
- if (isPremiumMember || price > 100) {
+ if (isPremiumMember || price >= 100) {
~
2. [Killed] BinaryExpression
~
- if (isPremiumMember || price > 100) {
+ if (isPremiumMember && price > 100) {
~
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
الخطوة الثالثة: تحليل النتائج
التقرير يخبرنا بأشياء مهمة:
- المتحوّل رقم 2 (Killed): قام Stryker بتغيير
||إلى&&. اختباراتنا فشلت (وهو المطلوب)، لأن حالة العضو المميز بسعر أقل من 100 (calculateFinalPrice(50, true)) لم تعد تمنح الخصم. هذا يعني أن اختبارنا جيد وقوي. - المتحوّل رقم 1 (Survived): هذا هو المهم! قام Stryker بتغيير
> 100إلى>= 100. ورغم هذا التغيير، نجحت كل اختباراتنا! شو القصة؟ السبب هو أننا لم نختبر أبداً الحالة الحدية، أي عندما يكون السعر 100 بالضبط. اختباراتنا لا تعرف ما الذي يجب أن يحدث في هذه الحالة.
الخطوة الرابعة: تقوية الاختبار وقتل “الزومبي”
لكي نقتل هذا المتحوّل الناجي، نحتاج إلى إضافة اختبار جديد يغطي هذه الحالة. لنسأل أنفسنا: ما هي النتيجة المتوقعة عندما يكون السعر 100 بالضبط؟ حسب منطقنا الأصلي (> 100)، لا يجب أن يكون هناك خصم. لنضف هذا الاختبار:
test('should not apply discount for price exactly 100', () => {
expect(calculateFinalPrice(100, false)).toBe(100);
});
الآن، إذا أعدنا تشغيل npx stryker run، سنجد أن المتحوّل الذي كان ينجو في السابق قد “قُتل” الآن، وسيرتفع “Mutation Score” الخاص بنا. لقد أصبحت اختباراتنا الآن أقوى وأكثر موثوقية.
نصائح من خبرة أبو عمر: كيف تستخدم الاختبار الطفري بفعالية
الاختبار الطفري مثل “الفحص الشامل” للكود؛ لا تقوم به كل يوم، لكنه ضروري للكشف عن العلل الخفية قبل أن تصبح مشاكل كبيرة. إنه يحولك من مجرد “كاتب اختبارات” إلى “مهندس جودة”.
- ابدأ بالتدريج: الاختبار الطفري يمكن أن يكون بطيئاً جداً على المشاريع الكبيرة. لا تحاول تشغيله على كامل المشروع من أول يوم. ابدأ بوحدة (module) جديدة أو بجزء حساس من النظام.
- لا تقدس نسبة الـ 100%: تماماً مثل تغطية الكود، الوصول إلى mutation score بنسبة 100% قد يكون غير عملي. ركز على إصلاح المتحولين الناجين في الأجزاء المنطقية الحرجة من تطبيقك.
- ادمجه في الـ CI/CD بحكمة: بدلاً من تشغيله مع كل commit، يمكنك تشغيله فقط على الملفات التي تغيرت في الـ Pull Request، أو يمكنك تخصيص “مهمة” (job) ليلية لتشغيله على المشروع بأكمله.
- افهم “المتحولين المكافئين” (Equivalent Mutants): أحياناً، يقوم Stryker بإنشاء متحول يغير الكود ولكنه لا يغير المنطق (مثلاً، تغيير
i++إلى++iداخل حلقةforقد لا يؤثر على النتيجة). هذه المتحولات لا يمكن “قتلها”. معظم الأدوات الحديثة جيدة في تجاهلها، ولكن أحياناً قد تحتاج إلى استثنائها يدوياً. مش كل طفرة لازم تنقتل! - استخدمه كأداة تعلم: أفضل استخدام للاختبار الطفري هو كأداة تعليمية لك ولفريقك. عندما ترى “متحولاً ناجياً”، لا تقم فقط بإصلاح الاختبار. اسأل نفسك: “لماذا لم أفكر في هذه الحالة أثناء كتابة الاختبار الأصلي؟”. هذا سيجعلك كاتباً أفضل للاختبارات في المستقبل.
خلاصة الكلام 💡
الثقة الزائدة في مقاييس مثل تغطية الكود هي فخ وقعنا فيه جميعاً. تغطية الكود تخبرك ما الذي اختبرته، بينما الاختبار الطفري يخبرك مدى جودة اختبارك له.
قد يبدو الأمر معقداً في البداية، لكن البدء في استخدامه على أجزاء صغيرة من مشروعك سيفتح عينيك على نقاط ضعف لم تكن لتراها أبداً. إنه السلاح السري الذي يحول مجموعة اختباراتك من مجرد شبكة أمان شكلية إلى درع فولاذي حقيقي يحمي جودة منتجك. جربوه، وستشكرونني لاحقاً. 🚀