“كل شي تمام يا أبو عمر، الاختبارات 100% ناجحة!”
أذكرها وكأنها البارحة. كنا نعمل على نظام مالي حساس لأحد العملاء الكبار. شهور من العمل الشاق، ليالٍ طويلة، وأكواب قهوة لا تُعد ولا تُحصى. الفريق كله كان على أعصابه، وأنا كنت مسؤولاً عن جودة الكود ووحدة الاختبارات (Unit Tests). قبل موعد الإطلاق بأسبوع، عقدنا اجتماعًا أخيرًا لمراجعة كل شيء.
فتح المبرمج الشاب أمامي لوحة التحكم الخاصة بالـ CI/CD بفخر، وأشار إلى الخط الأخضر الطويل: “تغطية الاختبارات 100% يا أبو عمر، كل شيء بمر زي الحلاوة!”. شعرت وقتها بمزيج من الفخر والاطمئنان. لقد فعلناها. بنينا حصنًا منيعًا حول منطقنا البرمجي. أو هكذا ظننت.
بعد الإطلاق بيومين فقط، رن هاتفي في ساعة متأخرة. كان العميل على الخط، صوته يرتجف من القلق. “أبو عمر، في كارثة! حسابات الخصومات كلها غلط!”. يا زلمة، كيف هيك صار؟ كيف يمكن أن يحدث خطأ فادح كهذا ونحن نملك تغطية اختبارات كاملة؟
بعد ليلة بيضاء من التحقيق، اكتشفنا المشكلة. كانت في دالة بسيطة للخصومات. أحد الشروط الحدودية (edge case) لم يتم اختباره بشكل صحيح. الاختبار كان موجودًا، وكان يمر، لكنه كان “غبيًا”. كان يتأكد من أن الدالة تعمل، لكنه لم يتأكد من أنها تعمل *بالشكل الصحيح* في كل الظروف. كان مجرد ديكور جميل يمنحنا شعورًا زائفًا بالأمان. تلك الليلة، تعلمت درسًا قاسيًا: العلامة الخضراء وتغطية 100% يمكن أن تكون أكبر كذبة نكذبها على أنفسنا كمبرمجين. ومن هنا بدأت رحلتي مع عالم سيغير مفهومي للجودة إلى الأبد: عالم الاختبار الطفري.
ما وراء العلامة الخضراء: وهم تغطية الاختبارات (Test Coverage)
قبل أن نغوص في الحل، دعونا نتفق على المشكلة. تغطية الاختبارات (Test Coverage) هي مقياس يخبرنا ما هي نسبة الكود المصدري التي تم تنفيذها أثناء تشغيل الاختبارات. تبدو فكرة رائعة، أليس كذلك؟ لكنها، يا جماعة الخير، سيف ذو حدين.
تغطية 100% تعني أن كل سطر في الكود قد تم “لمسه” من قبل اختبار واحد على الأقل. لكنها لا تخبرنا أي شيء عن جودة هذا “اللمس”. هل تأكد الاختبار من النتيجة الصحيحة؟ هل جرب كل المسارات المنطقية الممكنة؟
مثال على اختبار “أحمق” بتغطية 100%
لنفترض أن لدينا هذه الدالة البسيطة في JavaScript لتحديد إذا كان العمر صالحًا للحصول على تصريح معين:
// isEligible.js
function isEligible(age) {
if (age >= 18) {
return true;
} else {
return false;
}
}
والآن، لنكتب اختبارًا يحقق تغطية 100%:
// isEligible.test.js
test('should check eligibility', () => {
isEligible(25); // ننفذ الكود
isEligible(15); // ننفذ الكود
expect(true).toBe(true); // تأكيد سخيف لا علاقة له بالدالة!
});
هذا الاختبار سيمر بنجاح باهر، وستخبرك أداة قياس التغطية أنك غطيت 100% من الكود لأنك استدعيت الدالة بقيمتين مختلفتين (25 و 15) مما أدى إلى تنفيذ كل من جملتي `if` و `else`. لكن هل هذا الاختبار مفيد؟ بالطبع لا! إنه لا يتأكد من أن الدالة تُرجع `true` عندما يكون العمر 25، أو `false` عندما يكون 15. إنه مجرد ضجيج يولد ثقة زائفة.
الدخول إلى عالم “المُسُوخ”: ما هو الاختبار الطفري (Mutation Testing)؟
هنا يأتي دور البطل الحقيقي في قصتنا. الاختبار الطفري، أو كما أحب أن أسميه “مُفتش الجودة لاختباراتك”.
ببساطة، الاختبار الطفري هو تقنية تقوم بتغيير الكود المصدري الخاص بك عمدًا وبشكل طفيف (تخلق “طفرة” أو “مَسخًا” – Mutant)، ثم تقوم بتشغيل اختباراتك مرة أخرى. إذا فشل أحد اختباراتك، فهذا رائع! يعني أن اختبارك “قتل” هذه الطفرة واكتشف التغيير. أما إذا مرت كل اختباراتك بنجاح… فهذه هي الكارثة. هذا يعني أن لديك “طفرة ناجية” (Survived Mutant)، واختباراتك ضعيفة لدرجة أنها لم تلاحظ أن الكود قد تغير وأصبح معطوبًا.
الفكرة عبقرية: بدلًا من أن نثق باختباراتنا بشكل أعمى، نحن نتحداها ونختبر قوتها.
كيف يعمل الاختبار الطفري خطوة بخطوة؟
- التشغيل الأولي (Baseline): يتم تشغيل مجموعة اختباراتك بالكامل للتأكد من أنها كلها تمر بنجاح في الوضع الطبيعي.
- خلق الطفرات (Mutation): تقوم الأداة بنسخ الكود الخاص بك وتطبيق “مُحوِّر” (Mutator) واحد عليه. المُحوِّر هو قاعدة لتغيير الكود.
- تغيير `>` إلى `>=` أو `<`.
- تغيير `&&` إلى `||`.
- تغيير `+` إلى `-`.
- حذف استدعاء دالة معينة.
- تغيير قيمة `true` إلى `false`.
- اختبار الطفرة (Testing the Mutant): يتم تشغيل اختباراتك مرة أخرى ضد هذا الكود المُعدَّل (المَسخ).
- تحليل النتائج:
- طفرة مقتولة (Killed): أحد الاختبارات فشل. هذا هو المطلوب! اختباراتك قوية بما يكفي لاكتشاف هذا النوع من الأخطاء.
- طفرة ناجية (Survived): كل الاختبارات مرت بنجاح. هذه نقطة ضعف في اختباراتك ويجب عليك تحسينها.
- خطأ/مهلة (Error/Timeout): تسببت الطفرة في حلقة لا نهائية أو خطأ فادح. عادة ما تُعتبر “مقتولة”.
تكرر الأداة هذه العملية مئات أو آلاف المرات، مع كل طفرة ممكنة، لتعطيك في النهاية “คะแนน الطفرة” (Mutation Score)، وهو نسبة الطفرات المقتولة إلى إجمالي الطفرات.
مثال عملي: لنجعل الكود “يصرخ” عند الخطأ
دعنا نعد إلى دالة `isEligible` ونرى كيف سيكشف الاختبار الطفري ضعف اختبارنا الأول. سنستخدم أداة شهيرة في عالم JavaScript اسمها Stryker.
الكود الأصلي:
// isEligible.js
function isEligible(age) {
if (age >= 18) { // انتبه لهذا الشرط
return true;
} else {
return false;
}
}
الاختبار الضعيف:
// isEligible.test.js
test('should check eligibility', () => {
isEligible(20);
expect(true).toBe(true); // لا يؤكد شيئًا مفيدًا
});
عند تشغيل Stryker، سيقوم بإنشاء طفرات. واحدة من أهم الطفرات التي سيقوم بها هي تغيير عامل المقارنة:
- الطفرة رقم 1: سيغير `age >= 18` إلى `age > 18`.
- الطفرة رقم 2: سيغير `age >= 18` إلى `age < 18`.
عندما يشغل Stryker اختبارنا الضعيف ضد هذه الطفرات، سيمر الاختبار في كل مرة! لماذا؟ لأن الاختبار لا يتحقق أبدًا من القيمة التي تعيدها الدالة `isEligible`. وبالتالي، سيخبرنا التقرير أن لدينا “طفرات ناجية”.
النتيجة: Mutation Score منخفض جدًا، وهذا مؤشر خطر.
تحسين الاختبار لقتل الطفرات
الآن، لنكتب اختبارًا حقيقيًا وصارمًا:
// isEligible.test.js (النسخة المحسنة)
describe('isEligible', () => {
test('should return true for age greater than 18', () => {
expect(isEligible(25)).toBe(true);
});
test('should return true for age exactly 18', () => {
expect(isEligible(18)).toBe(true); // هذا الاختبار سيقتل الطفرة 'age > 18'
});
test('should return false for age less than 18', () => {
expect(isEligible(17)).toBe(false);
});
});
الآن، عندما يقوم Stryker بتشغيل طفرة `age > 18`، فإن الاختبار الثاني (`should return true for age exactly 18`) سيفشل، لأن `isEligible(18)` ستُرجع `false` في الكود المُطفَّر، بينما يتوقع الاختبار `true`. وهكذا، تم “قتل” الطفرة بنجاح!
عند إعادة تشغيل أداة الاختبار الطفري، سنرى أنคะแนน الطفرة قد ارتفع بشكل كبير، مما يمنحنا ثقة حقيقية، وليست زائفة، في جودة اختباراتنا.
نصائح أبو عمر الذهبية لتبني الاختبار الطفري
بعد سنوات من استخدام هذه التقنية، تعلمت بعض الدروس التي أود مشاركتها معكم:
- ابدأ صغيرًا: لا تحاول تطبيق الاختبار الطفري على مشروعك الضخم دفعة واحدة. العملية بطيئة وتستهلك موارد حسابية. ابدأ بوحدة برمجية (module) جديدة أو بجزء حرج من نظامك.
- لا تسعَ لنسبة 100%: تمامًا مثل تغطية الكود، الحصول على Mutation Score بنسبة 100% ليس دائمًا عمليًا أو ضروريًا. ركز على الأجزاء الحساسة من تطبيقك واهدف إلى تحسين النتيجة هناك.
- ادمجها في الـ CI/CD… ولكن بحذر: دمجها في مسار التكامل المستمر فكرة ممتازة، لكن لا تجعلها تعمل مع كل `commit`. قد يكون تشغيلها ليلاً (nightly build) أو عند إنشاء طلبات السحب (Pull Requests) على فروع معينة هو الحل الأمثل.
- افهم الطفرات الناجية: الطفرة الناجية ليست دائمًا دعوة لكتابة اختبار جديد. أحيانًا، قد تكشف عن كود ميت (dead code) لا يمكن الوصول إليه، أو عن منطق متكرر (redundant logic). تحليل سبب نجاة الطفرة هو بحد ذاته عملية مفيدة جدًا.
- هي أداة وليست غاية: تذكر دائمًا أن الهدف النهائي هو بناء برمجيات موثوقة وصامدة، وليس مجرد الحصول على أرقام عالية في التقارير. استخدم الاختبار الطفري كبوصلة ترشدك نحو نقاط الضعف في شبكة أمانك (اختباراتك).
الخلاصة: من مطور “واثق” إلى مطور “مطمئن” 😌
الاختبار الطفري نقلني من مطور يثق في العلامة الخضراء إلى مطور يطمئن إلى قوة اختباراته. لم يعد الهدف هو كتابة اختبارات “تمر”، بل كتابة اختبارات “تفشل” عند حدوث أي تغيير غير متوقع. إنه يغير طريقة تفكيرك من “هل الكود يعمل؟” إلى “هل يمكنني كسر هذا الكود؟ وإذا انكسر، هل ستصرخ اختباراتي؟”.
هذه التقنية ليست بديلاً عن الاختبارات الوحدوية أو اختبارات التكامل، بل هي طبقة إضافية من الحماية، ومُدقِّق جودة يضمن أن حراس بوابتك (اختباراتك) ليسوا نائمين في الخدمة.
نصيحتي الأخيرة لك: لا تثق بالاختبار الذي لم تحاول كسره عمدًا. جرّب الاختبار الطفري، وكن مستعدًا لتتفاجأ بمدى هشاشة بعض اختباراتك التي كنت تظنها منيعة.