الأدلة التقنية

اختبار الطفرات Mutation Testing: هل تكشف اختباراتك الأخطاء؟

لماذا تمر الأخطاء من اختبارات تغطيتها 100%، وكيف يقيس اختبار الطفرات قوتها فعلاً ويعمل في خط CI دون أن يبطئه.

اختبار الطفرات Mutation Testing: هل تكشف اختباراتك الأخطاء؟

تفتح تقرير التغطية Coverage في خط CI فتجد 100% على ملف حساب رسوم الشحن، وكل الاختبارات خضراء، ثم يصلك بعد النشر بلاغ من عميل دفع رسوم شحن على طلب قيمته 200 ريال بالضبط مع أن المتجر يعلن أن الشحن مجاني من 200 ريال. كيف حدث ذلك والسطر مغطى؟ والسبب أن التغطية تخبرك أن السطر نفذ أثناء الاختبار، ولا تخبرك هل يوجد اختبار يفشل إذا تغير هذا السطر، فإذا لم يكن في اختباراتك طلب بقيمة 200 بالضبط فإن تغيير >= إلى > يمر دون أن يلاحظه أحد.

وقد شرحنا في مقال أنواع الاختبارات للمبتدئين لماذا لا تكون 100% هدفاً، وشرحنا في مقال اختبارات يكتبها الذكاء الاصطناعي فكرة اختبار الطفرات Mutation Testing الأساسية، أي أن الأداة تصنع نسخاً من كودك في كل منها تغيير صغير، ثم تنظر هل قتلت اختباراتك الطفرة Killed أم نجت منها Survived، وطبقناها هناك على دالة تقسيم صفحات بأداة mutmut. وهذا المقال يكمل من حيث توقف ذلك المقال، ويدخل في التفاصيل التي تحتاجها عندما تريد تشغيل اختبار الطفرات في مشروع حقيقي.

وسوف نناقش في هذا المقال ما يلي:

  • عوامل الطفرة Mutation Operators: ما الذي تغيره الأداة بالضبط، ولماذا يكفي تغيير صغير واحد في كل مرة.
  • درجة الطفرات Mutation Score وكيف تحسب، وحالات الطفرة مثل Timeout وNo Coverage، والفرق بينها وبين تغطية الأسطر والفروع.
  • الطفرة المكافئة Equivalent Mutant: الطفرة التي لا يمكن قتلها، وكيف تتعامل معها دون أن تخدع الرقم.
  • التكلفة وطرق التسريع: تحليل التغطية لكل اختبار، والوضع التدريجي Incremental Mode، وتشغيل الطفرات على الملفات التي تغيرت فقط.
  • الحد الأدنى للدرجة في خط CI، والأدوات: StrykerJS للغة JavaScript وTypeScript، وPIT للغة Java، وmutmut وcosmic-ray للغة Python.
  • تجربة عملية على قاعدة رسوم شحن واحدة بثلاث لغات: تغطية 100% تمر منها طفرات ناجية، ثم اختبارات أقوى، والدرجة والزمن قبل وبعد.

ما الذي تغيره الأداة؟ عوامل الطفرة Mutation Operators

عامل الطفرة Mutation Operator هو قاعدة تحويل واحدة تطبقها الأداة على كل مكان مناسب في الكود، مثل «استبدل >= بـ >» أو «احذف محتوى هذا الشرط». والأداة لا تفهم منطق برنامجك، وإنما تمر على شجرة الكود AST وتطبق كل عامل على كل موضع يناسبه، فتخرج من دالة صغيرة من 13 سطراً عشرات الطفرات. والجدول التالي يبين العائلات الأشهر بأسمائها في StrykerJS، مع أمثلة من دالة رسوم الشحن التي سوف نستخدمها في التجربة:

العائلةالاسم في StrykerJSمثال من التجربةالخطأ البشري الذي تشبهه
حدود المقارنةEqualityOperatorsubtotal >= 200 إلى subtotal > 200الخطأ بواحد عند الحد Off-by-one
العمليات الحسابيةArithmeticOperatorweightKg - 5 إلى weightKg + 5إشارة خاطئة في معادلة
العمليات المنطقيةLogicalOperatormember || ... إلى member && ...«أو» مكان «و»
استبدال الشرط كاملاًConditionalExpressionif (weightKg > 5) إلى if (true)شرط لا يعمل أصلاً
حذف كتلة كودBlockStatementجسم if يصبح {}سطر نسي المطور كتابته
النصوصStringLiteral'empty cart' إلى ""رسالة خطأ فارغة أو خاطئة
عمليات الإسنادAssignmentOperatorfee += إلى fee -=جمع مكان طرح

والقائمة الكاملة لكل لغة في صفحة Supported mutators، ولكل أداة مجموعتها الخاصة، فمجموعة DEFAULTS في PIT مثلاً تضم حدود الشروط Conditionals Boundary والعمليات الحسابية Math ونفي الشروط Negate Conditionals وتغيير القيم المرجعة Returns، وهناك مجموعة أكبر اسمها STRONGER. وسوف ترى في التجربة أن اختلاف العوامل بين الأدوات ليس تفصيلاً، لأن أداة cosmic-ray للغة Python وجدت فجوة في اختباراتنا لم تولد لها StrykerJS ولا PIT أي طفرة.

وقد يتساءل البعض: لماذا تغيير صغير واحد في كل طفرة؟ أليست الأخطاء الحقيقية أعقد من ذلك؟ والإجابة على ذلك من الورقة التي بدأت هذا المجال، وهي Hints on Test Data Selection التي نشرها DeMillo وLipton وSayward في مجلة Computer سنة 1978، وفيها فرضيتان: الأولى فرضية المبرمج الكفء Competent Programmer Hypothesis، أي أن الكود الذي يكتبه مطور يفهم المهمة يكون قريباً من الصحيح، وأخطاؤه انحرافات صغيرة عنه. والثانية أثر الاقتران Coupling Effect، أي أن الاختبارات التي تكشف الأخطاء البسيطة تكشف معها غالباً الأخطاء المعقدة. وهذه فرضيات وليست براهين، لذلك اختبرها بحث René Just وزملائه في مؤتمر FSE سنة 2014 على 357 خطأ حقيقياً أصلحها المطورون في 5 مشاريع مفتوحة المصدر، فوجدوا ارتباطاً ذا دلالة إحصائية بين قدرة الاختبارات على قتل الطفرات وقدرتها على كشف الأخطاء الحقيقية، مستقلاً عن التغطية، ووجدوا أثر الاقتران في 73% من الأخطاء الحقيقية، وأن 10% منها تحتاج عامل طفرة جديداً أو أقوى، و17% لا يقترن بأي طفرة بطبيعته. والخلاصة العملية أن اختبار الطفرات مقياس جيد لقوة الاختبارات، لكنه لا يرى كل أنواع الأخطاء.

درجة الطفرات Mutation Score وحالات كل طفرة

بعد أن تشغل الأداة اختباراتك على كل طفرة تعطي كل واحدة حالة، والحالات في StrykerJS موثقة في صفحة Mutant States and Metrics وتستخدمها أيضاً أدوات Stryker للغة C# وScala، وهي كما يلي:

  • قتلت Killed: فشل اختبار واحد على الأقل والطفرة مفعلة، وهذا هو المطلوب.
  • نجت Survived: نجحت كل الاختبارات والطفرة مفعلة، أي أن اختباراتك تمر على هذا السطر ولا تتحقق من سلوكه.
  • بلا تغطية No Coverage: لا يوجد اختبار يمر على هذا الموضع أصلاً، فنجت الطفرة لأن أحداً لم يشغلها. وهذه الحالة هي ما تخبرك به التغطية العادية أيضاً.
  • انتهت المهلة Timeout: علقت الاختبارات، وغالباً لأن الطفرة صنعت حلقة لا تنتهي، وتحسب هذه الحالة كطفرة مكتشفة، لأن الاختبار في الواقع لم يكن ليمر.
  • خطأ تشغيل Runtime Error وخطأ ترجمة Compile Error: الطفرة نفسها غير صالحة، مثل كود TypeScript لا يمر من فحص الأنواع، ولا تدخل في الحساب.
  • متجاهلة Ignored: طفرة استثنيتها بتعليق أو إعداد، ولا تدخل في الحساب.

ومن هذه الحالات تحسب الدرجة بهذه المعادلة من الصفحة نفسها:

detected   = killed + timeout
undetected = survived + no coverage
valid      = detected + undetected
mutation score                  = detected / valid * 100
mutation score based on covered = detected / covered * 100

ولاحظ الدرجة الثانية، فهي تحسب الطفرات على الكود المغطى فقط، وبالتالي تفصل سؤالين عن بعضهما: هل تمر اختباراتي على هذا الكود؟ وهذا تجيب عليه التغطية، وإذا مرت عليه، هل تتحقق من سلوكه؟ وهذا يجيب عليه اختبار الطفرات وحده. والصورة التالية تبين الفرق بأرقام التجربة، حيث بقيت تغطية الأسطر والفروع 100% في التشغيلين، وتغيرت درجة الطفرات من 77.27% إلى 95.45%:

مخطط يقارن تشغيلين على الملف نفسه بتغطية أسطر 100% في الحالتين: الاختبارات الضعيفة تقتل 17 طفرة من 22 وتنجو 5 فتكون الدرجة 77.27%، والاختبارات القوية تقتل 21 وتنجو واحدة فتكون الدرجة 95.45%
التغطية لم تتحرك بين التشغيلين، والذي تغير هو عدد الطفرات التي لاحظتها الاختبارات

وهذه الأرقام تعني أن الطفرات الناجية هي قائمة عمل وليست مجرد رقم، فكل طفرة ناجية سؤال محدد: «إذا كتب أحد > 200 بدلاً من >= 200، هل يهمك ذلك؟». فإذا كان يهمك فأنت تحتاج اختباراً عند الحد، وإذا كان لا يهمك فإما أن الكود فيه شيء زائد، وإما أن الطفرة مكافئة، وهذا موضوع القسم التالي.

الطفرة المكافئة Equivalent Mutant: طفرة لا يمكن قتلها

الطفرة المكافئة Equivalent Mutant طفرة غيرت شكل الكود ولم تغير سلوكه لأي مدخل، وبالتالي لا يوجد أي اختبار يمكن أن يفشل بسببها، مهما كان دقيقاً. وفي تجربتنا ظهرت واحدة في الأدوات الثلاث: الشرط weightKg > 5 يضيف 4 ريالات لكل كيلوغرام يبدأ فوق 5 كغ، والطفرة weightKg >= 5 تدخل الشرط عند 5 كغ بالضبط، لكن الحساب داخله يصبح ceil(0) * 4 أي صفراً، فتكون الرسوم 15 ريالاً في الحالتين. والصورة التالية تبين ذلك:

مخطط الطفرة المكافئة: الكود الأصلي يستخدم أكبر من 5 والطفرة تستخدم أكبر من أو يساوي 5، وجدول الرسوم لأوزان 4 و5 و5.5 و9 كغ يعطي النتيجة نفسها في الحالتين لأن الحالة الوحيدة المختلفة عند 5 كغ تضيف صفراً، لذلك لا يمكن لأي اختبار أن يفشل ويقرر الإنسان تجاهلها مع كتابة السبب
الطفرة المكافئة تبقى ناجية مهما أضفت من اختبارات، والقرار فيها لإنسان يقرأ الكود

ولا توجد أداة تستطيع أن تقرر دائماً هل الطفرة مكافئة، لأن السؤال في حالته العامة يعود إلى مقارنة سلوك برنامجين لكل المدخلات، وهذا غير قابل للحل آلياً، لذلك تجد في التقرير الطفرات المكافئة مختلطة بالطفرات الناجية الحقيقية، وعليك أن تفرزها بنفسك. وعندما تتأكد أن الطفرة مكافئة، لا تقم بكتابة اختبار عبثي لإرضاء الأداة، وإنما استثنها بتعليق يشرح السبب، وفي StrykerJS تكتب تعليقاً بالصيغة الموثقة في صفحة Disable mutants:

  // Stryker disable next-line EqualityOperator: equivalent at exactly 5 kg, ceil(0) * 4 adds nothing
  if (weightKg > 5) {

ولاحظ أن التعليق يستثني عامل EqualityOperator كاملاً على هذا السطر، أي الطفرتين >= 5 و<= 5 معاً، مع أن الثانية كانت تقتل بشكل طبيعي، وسوف ترى ذلك في التجربة حيث ظهرت طفرتان بحالة Ignored. لذلك اجعل الاستثناء على أضيق نطاق ممكن، أي سطر واحد وعامل واحد، واكتب السبب دائماً، والسبب أن الاستثناء بلا سبب يتحول بعد سنة إلى مكان يختبئ فيه خطأ حقيقي ولا يعرف أحد لماذا استثني.

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

لماذا هو بطيء، وكيف تجعله أسرع؟

التكلفة الأساسية بسيطة: عدد الطفرات مضروباً في زمن الاختبارات التي تشغل لكل طفرة. ودالة من 13 سطراً أنتجت 22 طفرة في StrykerJS و55 طفرة في cosmic-ray، فتخيل مشروعاً من 50 ألف سطر واختبارات تأخذ خمس دقائق. لذلك لا تجد أداة جادة تشغل كل الاختبارات على كل طفرة، وإنما تستخدم ثلاث طرق للتسريع:

  • تحليل التغطية لكل اختبار Per-test Coverage Analysis: في التشغيل الأول Dry Run تسجل الأداة أي اختبار يمر على أي طفرة، ثم تشغل لكل طفرة الاختبارات التي تغطيها فقط. وفي StrykerJS هذا هو الإعداد الافتراضي coverageAnalysis: "perTest"، ومشغل Vitest تحديداً يستخدمه دائماً ويتجاهل أي قيمة أخرى بحسب صفحة المشغل. وفي التجربة شغلت الأداة في المتوسط 1.32 اختبار لكل طفرة بدلاً من 5. والطفرات التي لا يغطيها أي اختبار تعطى حالة No Coverage مباشرة دون تشغيل.
  • التشغيل المتوازي: تشغل StrykerJS افتراضياً عدداً من العمليات يساوي عدد الأنوية ناقص واحد إذا زادت على أربع، لذلك ظهر في سجل التجربة Creating 7 test runner process(es) على 8 أنوية.
  • الوضع التدريجي Incremental Mode: مع --incremental تحفظ StrykerJS النتائج في reports/stryker-incremental.json، وفي التشغيل التالي تعيد استخدام نتيجة الطفرة المقتولة إذا كان الاختبار الذي قتلها موجوداً ولم يتغير، ونتيجة الطفرة غير المقتولة إذا لم يظهر اختبار جديد يغطيها ولم تتغير الاختبارات. ولكن انتبه لحدوده المكتوبة في صفحة الوضع التدريجي: لا يلاحظ أي تغيير خارج الملفات المتحولة وملفات الاختبار، ولا تحديث المكتبات ولا متغيرات البيئة، لذلك شغل تشغيلاً كاملاً بخيار --force بشكل دوري.

وفي PIT تغير الوضع التدريجي مع الوقت، فصفحة Incremental analysis ما زالت تشرح الخيار withHistory كميزة تجريبية، لكن سجل التغييرات في مستودع PIT يذكر في الإصدار 1.23.0 نقل تحليل التاريخ الافتراضي إلى إضافة Plugin، وعندما شغلنا -DwithHistory=true مع PIT 1.30.0 ظهرت رسالة تقول إن التاريخ مفعل ولا توجد إضافة له، وتقترح إضافة Arcmutate التجارية، كما سوف ترى في التجربة.

والطريقة الأهم من كل ما سبق لا تحتاج ميزة خاصة: شغل الطفرات على الملفات التي تغيرت في طلب الدمج فقط، عبر الخيار --mutate في StrykerJS أو targetClasses في PIT. وهذا ما تفعله Google على نطاق كبير، فقد وصف Goran Petrović وMarko Ivanković في ورقة State of Mutation Testing at Google في مؤتمر ICSE سنة 2018 نظاماً يعمل على التغييرات Diffs وليس على المشروع كاملاً، ويتجاهل الأسطر غير المغطاة والأسطر التي يسمونها «جافة» Arid Lines مثل سطور التسجيل Logging التي لا تفيد طفراتها، ويعرض الطفرات الناجية داخل مراجعة الكود، وذكرت الورقة أن النظام يستخدمه 6000 مهندس على التغييرات التي يكتبونها أو يراجعونها.

الحد الأدنى للدرجة في خط CI

في StrykerJS ثلاث قيم في thresholds بحسب صفحة الإعدادات: high وقيمتها الافتراضية 80 وما فوقها يعرض بالأخضر، وlow وقيمتها الافتراضية 60 وما تحتها يعرض كتحذير، وbreak وقيمتها الافتراضية null، وإذا وضعت لها رقماً فإن أي درجة أقل منه تجعل الأمر ينتهي برمز خروج 1، أي يفشل ال Job في خط CI. والمقابل في PIT هو mutationThreshold في إعداد الإضافة في Maven، وسوف ترى الاثنين يفشلان في التجربة.

والصورة التالية تبين ترتيباً عملياً لفريق صغير: طلب الدمج يشغل الطفرات على ما تغير فقط مع حد أدنى، والتشغيل الكامل يتم ليلاً وتراجع قائمة الطفرات الناجية فيه بهدوء:

مخطط مسار CI: طلب الدمج يشغل اختبارات الوحدة ثم اختبار الطفرات على الملفات المتغيرة فقط مع الوضع التدريجي، ثم بوابة الحد الأدنى للدرجة التي تفشل ال Job إذا قلت الدرجة، ثم مراجعة الكود مع الطفرات الناجية، وفي الليل تشغيل كامل على المشروع ينتج قائمة الطفرات الناجية لإضافة اختبار أو تسجيلها كمكافئة مع السبب
طلب الدمج يدفع ثمن ما تغير فقط، والتشغيل الكامل يتم حيث لا ينتظر أحد

وكيف تختار رقم break؟ لا تبدأ برقم مرتفع على مشروع قديم، والسبب أن أول تشغيل على كود قديم يعطي غالباً درجة منخفضة، فإذا وضعت الحد عند 80 توقف كل شيء وسوف يطلب الفريق إلغاء الأداة. والأنسب أن تشغل الأداة أولاً دون حد وتسجل الدرجة الحالية، ثم تضع الحد تحتها بقليل حتى لا تنخفض، ثم ترفعه كلما ارتفعت الدرجة، وتطلب للكود الجديد في طلب الدمج درجة أعلى، وهذه هي فكرة الكود الجديد New Code التي شرحناها في مقال جودة الكود.

الأدوات لكل لغة

الجدول التالي يجمع الأدوات التي شغلناها في التجربة، والتراخيص منقولة من ملف الترخيص في مستودع كل أداة كما وجدناه في أكتوبر 2026:

الأداةاللغةالترخيصالإصدار المختبرملاحظات من التجربة
StrykerJSJavaScript وTypeScriptApache-2.010.0.0تقرير HTML ممتاز، ووضع تدريجي مدمج، وتحتاج مشغلاً متوافقاً مع إصدار إطار الاختبار
PITJava وJVMApache-2.01.30.0سريع لأنه يعدل ال Bytecode مباشرة، ويحتاج إضافة pitest-junit5-plugin مع JUnit 5 وما بعده
cosmic-rayPythonMIT8.7.0عوامل كثيرة جداً، وجلسة محفوظة في SQLite، وأبطأ الثلاثة في التجربة
mutmutPythonBSD-3-Clause3.8.0استخدمناه في مقال الاختبارات بالذكاء الاصطناعي، ويحتاج نظاماً يدعم fork

وتوجد أدوات Stryker أخرى للغة C# باسم Stryker.NET وللغة Scala باسم Stryker4s بالحالات والتقرير نفسيهما، ولم نشغلهما في هذه التجربة. وإذا كنت تبحث عن أدوات فحص الكود الثابت وليس الاختبارات، فقد قارناها في مقارنة أدوات جودة الكود.

تجربة عملية: رسوم الشحن بثلاث لغات

سوف نأخذ قاعدة رسوم شحن من متجر صغير ونكتبها بثلاث لغات: السلة الفارغة خطأ، والأعضاء والطلبات من 200 ريال فأكثر شحنها مجاني، وغير ذلك 15 ريالاً، ويضاف 4 ريالات لكل كيلوغرام يبدأ فوق 5 كغ. ونكتب لها اختبارات تحقق تغطية 100% لكنها ضعيفة بطريقة مألوفة في المشاريع الحقيقية، ثم نشغل أداة الطفرات ونقوي الاختبارات ونقيس الدرجة والزمن. وكل التشغيلات تمت داخل Containers على Docker Engine 29.8.0 بثمانية أنوية مخصصة لـ Docker، والأوقات توضيحية لهذا الجهاز فقط، والجهاز كان يشغل تجارب أخرى في الوقت نفسه.

القاعدة والاختبارات الأولى

الكود بنسخة TypeScript كما يلي، وهو في المجلد lab/js من حزمة المقال:

// Shipping fee rule of a small online store (amounts in SAR):
// - an empty cart is an error
// - members and orders of 200 SAR or more ship free
// - otherwise the fee is 15 SAR, plus 4 SAR for every started kilogram above 5 kg
export function shippingFee(subtotal: number, weightKg: number, member: boolean): number {
  if (subtotal <= 0) {
    throw new Error('empty cart');
  }
  if (member || subtotal >= 200) {
    return 0;
  }
  let fee = 15;
  if (weightKg > 5) {
    fee += Math.ceil(weightKg - 5) * 4;
  }
  return fee;
}

والاختبارات الأولى خمسة، كل منها يمر على فرع من الفروع:

// First version: every line runs, but the checks are loose.
import { describe, expect, it } from 'vitest';
import { shippingFee } from '../src/shipping';

describe('shippingFee', () => {
  it('rejects an empty cart', () => {
    expect(() => shippingFee(0, 1, false)).toThrow();
  });
  it('is free for members', () => {
    expect(shippingFee(50, 1, true)).toBe(0);
  });
  it('is free for big orders', () => {
    expect(shippingFee(350, 1, false)).toBe(0);
  });
  it('charges the base fee for a light parcel', () => {
    expect(shippingFee(100, 2, false)).toBe(15);
  });
  it('charges more for a heavy parcel', () => {
    expect(shippingFee(100, 9, false)).toBeGreaterThan(15);
  });
});

وإعداد StrykerJS في stryker.config.json كما يلي:

{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "testRunner": "vitest",
  "mutate": ["src/**/*.ts"],
  "coverageAnalysis": "perTest",
  "reporters": ["clear-text", "progress", "html"],
  "thresholds": { "high": 90, "low": 80, "break": 85 }
}

والإصدارات مثبتة في package.json: @stryker-mutator/core و@stryker-mutator/vitest-runner بالإصدار 10.0.0، وVitest و@vitest/coverage-v8 بالإصدار 4.1.11، وTypeScript 7.0.2، وتعمل على Node.js 24.21.0 داخل Container من mcr.microsoft.com/playwright:v1.64.0-noble. أما لماذا Vitest 4 وليس 5، فهذا ما سوف تراه في خطوة الكسر لاحقاً.

docker run -d --name lab-qa3-mut-js -v "$PWD:/work" -v lab-qa3-mut-nm:/work/node_modules \
  -w /work mcr.microsoft.com/playwright:v1.64.0-noble sleep infinity
docker exec lab-qa3-mut-js npm install
docker exec lab-qa3-mut-js npx vitest run --coverage

والمخرج سوف يكون كما يلي:

 ✓ test/shipping.test.ts (5 tests) 15ms

 Test Files  1 passed (1)
      Tests  5 passed (5)

 % Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files    |     100 |      100 |     100 |     100 |
 shipping.ts |     100 |      100 |     100 |     100 |
-------------|---------|----------|---------|---------|-------------------

تغطية 100% في الأسطر والفروع والدوال، وأي تقرير جودة سوف يعطي هذا الملف علامة كاملة.

StrykerJS على الاختبارات الأولى

docker exec lab-qa3-mut-js npx stryker run

والمخرج سوف يكون كما يلي، مختصراً على الطفرات الناجية والجدول:

INFO Instrumenter Instrumented 1 source file(s) with 22 mutant(s)
INFO ConcurrencyTokenProvider Creating 7 test runner process(es).
INFO DryRunExecutor Initial test run succeeded. Ran 5 tests in 3 seconds

[Survived] StringLiteral
src/shipping.ts:7:21
-       throw new Error('empty cart');
+       throw new Error("");

[Survived] EqualityOperator
src/shipping.ts:9:17
-     if (member || subtotal >= 200) {
+     if (member || subtotal > 200) {

[Survived] EqualityOperator
src/shipping.ts:13:7
-     if (weightKg > 5) {
+     if (weightKg >= 5) {

[Survived] ArithmeticOperator
src/shipping.ts:14:22
-       fee += Math.ceil(weightKg - 5) * 4;
+       fee += Math.ceil(weightKg + 5) * 4;

[Survived] ArithmeticOperator
src/shipping.ts:14:12
-       fee += Math.ceil(weightKg - 5) * 4;
+       fee += Math.ceil(weightKg - 5) / 4;

Ran 1.32 tests per mutant on average.
-------------|--------|---------|----------|-----------|------------|----------|----------|
File         |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
-------------|--------|---------|----------|-----------|------------|----------|----------|
All files    |  77.27 |   77.27 |       17 |         0 |          5 |        0 |        0 |
-------------|--------|---------|----------|-----------|------------|----------|----------|
ERROR MutationTestReportHelper Final mutation score 77.27 under breaking threshold 85, setting exit code to 1 (failure).
INFO MutationTestExecutor Done in 31 seconds.

في المخرج أعلاه لاحظ التالي:

  • الملف المغطى 100% أنتج 22 طفرة، نجت منها 5، فكانت الدرجة 77.27%، وبما أنها أقل من break وهو 85 فقد انتهى الأمر برمز خروج 1، وهذا ما يوقف ال Job في خط CI.
  • الطفرة subtotal > 200 هي الخطأ الذي بدأنا به المقال: لا يوجد اختبار بقيمة 200 بالضبط، والاختبار بقيمة 350 يمر في الحالتين.
  • طفرتا الحساب weightKg + 5 و/ 4 نجتا لأن الاختبار يقول «أكبر من 15» فقط، وأي رقم أكبر من 15 يرضيه، أي أن السطر نفذ ولم يتحقق أحد من نتيجته.
  • طفرة الرسالة الفارغة نجت لأن toThrow() بدون رسالة يقبل أي خطأ، حتى خطأ لا علاقة له بالسلة الفارغة.
  • الطفرة weightKg >= 5 هي الطفرة المكافئة التي شرحناها، وسوف تبقى ناجية مهما فعلنا.
  • في أول تشغيل لنا بعد التثبيت ظهرت 7 طفرات بحالة Timeout بدلاً من Killed، والجهاز كان مشغولاً، ولم تتكرر في التشغيلات التالية. والدرجة لم تتأثر كثيراً لأن Timeout تحسب كطفرة مكتشفة، لكن إذا رأيتها في كل تشغيل على كود لا يحتوي حلقات فارفع timeoutMS بدلاً من أن تثق بها.

اختبارات أقوى، والدرجة بعدها

الآن نقوي الاختبارات بثلاثة تغييرات: رسالة الخطأ محددة، وقيم دقيقة بدلاً من «أكبر من»، واختبار عند كل حد، أي 200 و199.99 للمبلغ و5 و5.5 للوزن:

// Second version: the same five cases, plus exact values and the two boundaries.
import { describe, expect, it } from 'vitest';
import { shippingFee } from '../src/shipping';

describe('shippingFee', () => {
  it('rejects an empty cart with a clear message', () => {
    expect(() => shippingFee(0, 1, false)).toThrow('empty cart');
  });
  it('is free for members', () => {
    expect(shippingFee(50, 1, true)).toBe(0);
  });
  it('is free from exactly 200 SAR', () => {
    expect(shippingFee(200, 1, false)).toBe(0);
    expect(shippingFee(199.99, 1, false)).toBe(15);
  });
  it('charges the base fee up to exactly 5 kg', () => {
    expect(shippingFee(100, 2, false)).toBe(15);
    expect(shippingFee(100, 5, false)).toBe(15);
  });
  it('adds 4 SAR for every started kilogram above 5 kg', () => {
    expect(shippingFee(100, 5.5, false)).toBe(19);
    expect(shippingFee(100, 9, false)).toBe(31);
  });
});

وعند تشغيل الأمر نفسه سوف تجد أنه:

[Survived] EqualityOperator
src/shipping.ts:13:7
-     if (weightKg > 5) {
+     if (weightKg >= 5) {

Ran 1.27 tests per mutant on average.
-------------|--------|---------|----------|-----------|------------|----------|----------|
File         |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
-------------|--------|---------|----------|-----------|------------|----------|----------|
All files    |  95.45 |   95.45 |       21 |         0 |          1 |        0 |        0 |
-------------|--------|---------|----------|-----------|------------|----------|----------|
INFO MutationTestReportHelper Final mutation score of 95.45 is greater than or equal to break threshold 85
INFO MutationTestExecutor Done in 18 seconds.

الدرجة ارتفعت من 77.27% إلى 95.45% دون أن تتغير التغطية، والطفرة الوحيدة الباقية هي المكافئة. ولاحظ أن الاختبار shippingFee(100, 5, false) لم يقتلها، فهو يمر على الحد بالضبط ويحصل على 15 في الحالتين، وهذا دليل عملي على أنها مكافئة وليست ضعفاً في الاختبار. والآن نضيف تعليق الاستثناء الذي رأيته في قسم الطفرة المكافئة فوق الشرط، ونشغل مرة أخرى:

-------------|--------|---------|----------|-----------|------------|----------|----------|
File         |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
-------------|--------|---------|----------|-----------|------------|----------|----------|
All files    | 100.00 |  100.00 |       20 |         0 |          0 |        0 |        0 |
-------------|--------|---------|----------|-----------|------------|----------|----------|
INFO MutationTestExecutor Done in 14 seconds.

والعدد أصبح 20 بدلاً من 22، لأن التقرير بصيغة JSON يبين طفرتين بحالة Ignored مع السبب الذي كتبناه:

weightKg >= 5 equivalent at exactly 5 kg, ceil(0) * 4 adds nothing
weightKg <= 5 equivalent at exactly 5 kg, ceil(0) * 4 adds nothing
{ Ignored: 2, Killed: 20 }

والثانية منهما <= 5 ليست مكافئة وكانت تقتل، لكن التعليق يستثني العامل كاملاً على السطر كما ذكرنا، وهذا ثمن مقبول هنا لأن اختبارات الحد موجودة، لكنه سبب إضافي لأن تكتب الاستثناء على سطر واحد فقط.

الوضع التدريجي: ماذا يوفر؟

docker exec lab-qa3-mut-js npx stryker run --incremental
docker exec lab-qa3-mut-js npx stryker run --incremental

التشغيل الأول أخذ 23.7 ثانية وكتب الملف reports/stryker-incremental.json، والثاني دون أي تغيير أخذ 15.4 ثانية، وطبع ما يلي:

INFO IncrementalDiffer Incremental report:
	Mutants:	0 files changed (+0 -0)
	Tests:		0 files changed (+0 -0)
	Result:		20 of 22 mutant result(s) are reused.
INFO MutationTestExecutor Done in 10 seconds.

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

نكسر التجربة: درجة صفر لأن المشغل لا يعمل

في البداية ثبتنا أحدث إصدار من Vitest وهو 5.0.4 مع المشغل @stryker-mutator/vitest-runner بالإصدار 10.0.0، والمشغل يعلن في ملف package.json أنه يقبل vitest >=2.0.0، فثبت كل شيء دون أي تحذير، وكانت النتيجة كما يلي:

[Survived] ConditionalExpression
src/shipping.ts:9:7
-     if (member || subtotal >= 200) {
+     if (false) {
Tests ran:
    shippingFee is free for members
    shippingFee is free for big orders
    shippingFee charges the base fee for a light parcel
  and 1 more test!

Ran 0.00 tests per mutant on average.
-------------|--------|---------|----------|-----------|------------|----------|----------|
File         |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
-------------|--------|---------|----------|-----------|------------|----------|----------|
All files    |   0.00 |    0.00 |        0 |         0 |         22 |        0 |        0 |
-------------|--------|---------|----------|-----------|------------|----------|----------|

كل الطفرات «نجت»، حتى طفرة if (false) التي تجعل الشحن غير مجاني للأعضاء، مع أن اختبار الأعضاء موجود ويجب أن يفشل. والعلامة الواضحة هنا السطر Ran 0.00 tests per mutant on average، أي أن الطفرات لم تشغل عليها أي اختبارات فعلاً. وعندما أرجعنا Vitest إلى 4.1.11 عملت الأداة كما رأيت أعلاه. ولم نبحث في السبب الداخلي، والمهم هنا الدرس: إذا رأيت درجة صفر أو قريبة منه على كود له اختبارات جيدة فلا تبدأ بكتابة اختبارات، وإنما تحقق أولاً من أن الأداة تعمل، وأسهل طريقة أن تتأكد أن طفرة واضحة مثل if (false) على فرع مختبر قد قتلت، وأن متوسط الاختبارات لكل طفرة أكبر من صفر. والعكس صحيح أيضاً، فدرجة 100% من أداة لم تتحقق من عملها لا تعني شيئاً.

القاعدة نفسها في Java مع PIT

نسخة Java في المجلد lab/java، والكود مطابق للنسخة السابقة، والاختبارات بالمستويين نفسيهما في JUnit 6.1.3. والجزء المهم من pom.xml هو إضافة PIT:

<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.30.0</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.3</version>
    </dependency>
  </dependencies>
  <configuration>
    <targetClasses><param>lab.*</param></targetClasses>
    <targetTests><param>lab.*</param></targetTests>
    <mutators><param>DEFAULTS</param></mutators>
    <outputFormats><param>HTML</param><param>XML</param></outputFormats>
    <timestampedReports>false</timestampedReports>
    <mutationThreshold>85</mutationThreshold>
  </configuration>
</plugin>

ونشغل الاختبارات مع JaCoCo 0.8.15 للتغطية، ثم PIT، داخل Container من maven:3.10.0-eclipse-temurin-21-alpine (Maven 3.10.0 وJava 21.0.12.1):

docker run -d --name lab-qa3-mut-java -v "$PWD:/work" -v lab-qa3-mut-m2:/root/.m2 \
  -w /work maven:3.10.0-eclipse-temurin-21-alpine sleep infinity
docker exec lab-qa3-mut-java mvn -B -q test
docker exec lab-qa3-mut-java cat target/site/jacoco/jacoco.csv
docker exec lab-qa3-mut-java mvn -B org.pitest:pitest-maven:mutationCoverage

وتقرير JaCoCo للاختبارات الضعيفة يبين أن كل الأسطر والفروع مغطاة:

GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,...
shipping,lab,Shipping,0,35,0,8,0,8,...

ومخرج PIT على الاختبارات الضعيفة، مع الطفرات من ملف mutations.xml:

>> Line Coverage (for mutated classes only): 8/9 (89%)
>> Generated 11 mutations Killed 7 (64%)
>> Mutations with no coverage 0. Test strength 64%
>> Ran 24 tests (2.18 tests per mutation)
[ERROR] Failed to execute goal org.pitest:pitest-maven:1.30.0:mutationCoverage (default-cli) on project shipping: Mutation score of 64 is below threshold of 85

KILLED line 8 ConditionalsBoundaryMutator: changed conditional boundary
SURVIVED line 11 ConditionalsBoundaryMutator: changed conditional boundary
SURVIVED line 15 ConditionalsBoundaryMutator: changed conditional boundary
SURVIVED line 16 MathMutator: Replaced double subtraction with addition
SURVIVED line 16 MathMutator: Replaced integer multiplication with division
KILLED line 16 MathMutator: Replaced integer addition with subtraction
KILLED line 11 RemoveConditionalMutator_EQUAL_ELSE: removed conditional - replaced equality check with false
KILLED line 8 RemoveConditionalMutator_ORDER_ELSE: removed conditional - replaced comparison check with false
KILLED line 11 RemoveConditionalMutator_ORDER_ELSE: removed conditional - replaced comparison check with false
KILLED line 15 RemoveConditionalMutator_ORDER_ELSE: removed conditional - replaced comparison check with false
KILLED line 18 PrimitiveReturnsMutator: replaced int return with 0 for lab/Shipping::fee

ثم نستبدل ملف الاختبار بالنسخة القوية، وهي في lab/java/versions/ShippingTest.strong.java، ونشغل مرة أخرى:

>> Generated 11 mutations Killed 10 (91%)
>> Mutations with no coverage 0. Test strength 91%
>> Ran 22 tests (2 tests per mutation)
[INFO] BUILD SUCCESS

SURVIVED line 15 ConditionalsBoundaryMutator: changed conditional boundary

في المخرج أعلاه لاحظ التالي:

  • PIT أنتج 11 طفرة فقط مقابل 22 في StrykerJS للكود نفسه، لأنه يعمل على ال Bytecode وبمجموعة DEFAULTS، ولا يغير النصوص، لذلك لم يكتشف ضعف التحقق من رسالة الخطأ أصلاً.
  • الدرجة ارتفعت من 64% إلى 91%، والطفرة الباقية في السطر 15 هي الطفرة المكافئة نفسها >= 5.
  • PIT يقول إن تغطية الأسطر 8 من 9، بينما JaCoCo يقول 8 من 8 للكلاس نفسه، والسبب أن كل أداة تعد الأسطر بطريقتها، لذلك لا تقارن أرقام التغطية بين أداتين مختلفتين.
  • الحد mutationThreshold أفشل البناء على الاختبارات الضعيفة بـ BUILD FAILURE، وهذا ما يوقف خط CI.

وفي الوقت، أخذ تشغيل PIT بين 16 و24 ثانية بحسب سطر Total في قسم Timings، ومنها نحو 7 ثوان لتحليل التغطية والاعتماديات Coverage and Dependency Analysis، والزمن الكلي مع Maven بين 17 و37 ثانية بحسب انشغال الجهاز. ثم شغلنا الوضع التدريجي:

docker exec lab-qa3-mut-java mvn -B -o -DwithHistory=true org.pitest:pitest-maven:mutationCoverage
[INFO] Will read history at /tmp/lab.shipping.1.0_pitest_history.bin
[INFO] Will write history at /tmp/lab.shipping.1.0_pitest_history.bin
[ERROR] History has been enabled but no history plugin has been installed/activated.
[ERROR] If you are using https://www.arcmutate.com remember to activate the history plugin with +arcmutate_history

أي أن PIT مفتوح المصدر بالإصدار 1.30.0 لا يعيد استخدام النتائج بنفسه، وعليك إما أن تحدد الملفات المتغيرة عبر targetClasses في طلب الدمج، وإما أن تستخدم إضافة تاريخ مثل Arcmutate.

القاعدة نفسها في Python مع cosmic-ray

نسخة Python في المجلد lab/python، واخترنا cosmic-ray 8.7.0 هنا لأن mutmut جربه القارئ في المقال السابق، وتعمل مع pytest 9.1.1 على Python 3.13.16. وإعدادها ملف TOML قصير:

[cosmic-ray]
module-path = "shipping.py"
timeout = 10.0
excluded-modules = []
test-command = "python -m pytest -x -q tests"

[cosmic-ray.distributor]
name = "local"
docker run -d --name lab-qa3-mut-py -v "$PWD:/work" -w /work python:3.13.16-slim sleep infinity
docker exec lab-qa3-mut-py pip install cosmic-ray==8.7.0 pytest==9.1.1 pytest-cov
docker exec lab-qa3-mut-py python -m pytest -q --cov=shipping --cov-branch --cov-report=term tests
docker exec lab-qa3-mut-py cosmic-ray init cosmic-ray.toml session.sqlite
docker exec lab-qa3-mut-py cosmic-ray exec cosmic-ray.toml session.sqlite
docker exec lab-qa3-mut-py cr-report session.sqlite

والتغطية مع الاختبارات الضعيفة 100% في الأسطر والفروع:

Name          Stmts   Miss Branch BrPart  Cover
-----------------------------------------------
shipping.py      10      0      6      0   100%
-----------------------------------------------
TOTAL            10      0      6      0   100%
5 passed in 2.63s

ومخرج cr-report على الاختبارات الضعيفة:

total jobs: 55
complete: 55 (100.00%)
surviving mutants: 29 (52.73%)

أي أن cosmic-ray أنتج 55 طفرة للدالة نفسها، منها 14 طفرة تغير الأرقام NumberReplacer مثل 200 إلى 201 و5 إلى 4، وعدد كبير من استبدال العمليات الحسابية مثل - إلى % و<<، ونجت 29 منها، أي أن الدرجة 47.27%. ثم شغلناه على النسخة القوية من الاختبارات، وهي النسخة نفسها التي قتلت كل شيء تقريباً في StrykerJS وPIT، مع إضافة اختبار لسلة بقيمة سالبة:

surviving mutants: 5 (9.09%)

shipping.py core/ReplaceBinaryOperator_Sub_Mod 0
-        fee += math.ceil(weight_kg - 5) * 4
+        fee += math.ceil(weight_kg % 5) * 4
shipping.py core/ReplaceComparisonOperator_Gt_GtE 0
-    if weight_kg > 5:
+    if weight_kg >= 5:
shipping.py core/ReplaceComparisonOperator_GtE_Eq 0
-    if member or subtotal >= 200:
+    if member or subtotal == 200:
shipping.py core/NumberReplacer 0
-    if subtotal <= 0:
+    if subtotal <= 1:
shipping.py core/NumberReplacer 9
-    if weight_kg > 5:
+    if weight_kg > 4:

وهنا أهم ما في التجربة، فهذه الطفرات كشفت ثلاث فجوات حقيقية لم تولد لها الأداتان الأخريان أي طفرة:

  • subtotal == 200: عندما قوينا الاختبارات حذفنا دون أن ننتبه اختبار الطلب بقيمة 350، فأصبح الشحن المجاني مختبراً عند 200 بالضبط فقط، وكود يجعل الشحن مجانياً عند 200 وحدها ينجح في كل الاختبارات. وهذا الخطأ نفسه كان موجوداً في نسخة TypeScript وJava، لكن StrykerJS وPIT لا تحولان >= إلى ==.
  • weight_kg % 5: بالصدفة يعطي 5.5 كغ و9 كغ النتيجة نفسها مع الطرح والباقي، أي 19 و31، ويحتاج قتلها وزناً مثل 11 كغ، فالرسوم الصحيحة 39 والطفرة تعطي 19.
  • subtotal <= 1: لا يوجد اختبار لسلة قيمتها بين 0 و1، مثل 0.5 ريال.

أما الطفرتان الباقيتان فمكافئتان: >= 5 التي رأيتها، و> 4 التي تدخل الشرط بين 4 و5 كغ لكن math.ceil لأي قيمة بين سالب واحد وصفر يعطي صفراً، فلا يتغير شيء. وبعد إضافة الاختبارات الثلاثة في النسخة الثالثة versions/test_shipping.third.py كانت النتيجة:

total jobs: 55
complete: 55 (100.00%)
surviving mutants: 2 (3.64%)
shipping.py core/ReplaceComparisonOperator_Gt_GtE 0
-    if weight_kg > 5:
+    if weight_kg >= 5:
shipping.py core/NumberReplacer 9
-    if weight_kg > 5:
+    if weight_kg > 4:

وفي الزمن كانت cosmic-ray الأبطأ بوضوح: 155 ثانية للاختبارات الضعيفة و56 ثانية للقوية و75 للثالثة، والسبب أنها في هذا الإعداد المحلي تشغل pytest كعملية جديدة لكل طفرة، واحدة بعد الأخرى، ومع الاختبارات الضعيفة تمر كل الاختبارات على الطفرات الناجية فلا يتوقف -x مبكراً. ويمكن توزيعها على عدة عمليات عبر موزع HTTP كما في وثائقها، ولم نجرب ذلك هنا.

النتائج في جدول واحد

الأداةعدد الطفراتالتغطيةالدرجة مع الاختبارات الضعيفةالدرجة مع الاختبارات القويةالطفرات الباقيةالزمن التقريبي
StrykerJS 10.0.022100%77.27%95.45%واحدة مكافئة14 إلى 31 ثانية
PIT 1.30.011100%64%91%واحدة مكافئة16 إلى 24 ثانية
cosmic-ray 8.7.055100%47.27%90.91% ثم 96.36%اثنتان مكافئتان56 إلى 155 ثانية

ولا تقارن الدرجات بين الأدوات، فكل أداة تحسب على مجموعة طفرات مختلفة، و91% في PIT لا تعني اختبارات أفضل من 90.91% في cosmic-ray. والذي يصح مقارنته هو درجة الأداة نفسها قبل التغيير وبعده على الكود نفسه.

إزالة التجربة

docker rm -f lab-qa3-mut-js lab-qa3-mut-java lab-qa3-mut-py
docker volume rm lab-qa3-mut-nm lab-qa3-mut-m2
docker rmi maven:3.10.0-eclipse-temurin-21-alpine

أخطاء شائعة

  • اعتبار التغطية 100% دليلاً على قوة الاختبارات: رأيت في التجربة ثلاث مجموعات اختبارات بتغطية كاملة ودرجات طفرات بين 47% و77%.
  • الثقة برقم الأداة قبل التحقق من أنها تعمل: درجة صفر مع Vitest 5 كانت مشكلة توافق وليست اختبارات سيئة، فتأكد أن طفرة واضحة قد قتلت وأن متوسط الاختبارات لكل طفرة أكبر من صفر.
  • مطاردة 100% بكتابة اختبارات لطفرات مكافئة: الطفرة المكافئة لا تقتل، والاختبار الذي يكتب لها اختبار عبثي يزيد الصيانة فقط، فاستثنها بتعليق وسبب.
  • الاستثناء الواسع: استثناء عامل كامل في إعداد المشروع أو ملف كامل يخفي طفرات حقيقية، كما رأيت حين استثنى تعليق على سطر واحد طفرة كانت تقتل.
  • تشغيل المشروع كاملاً في كل طلب دمج: يجعل خط CI بطيئاً حتى يطلب الفريق إيقافه، والأنسب الملفات المتغيرة في الطلب والتشغيل الكامل ليلاً.
  • حد أدنى مرتفع من اليوم الأول على كود قديم: ابدأ من الدرجة الحالية وارفعها تدريجياً.
  • مقارنة الدرجات بين أدوات مختلفة: 11 طفرة في PIT مقابل 55 في cosmic-ray للكود نفسه.
  • إصلاح الطفرة الناجية بتغيير الاختبار حتى تموت دون فهمها: الطفرة الناجية سؤال عن المتطلب، فإذا لم تعرف هل الحد 200 داخل في المجاني أم لا، فاسأل صاحب المتطلب قبل أن تكتب الاختبار.
  • الاعتقاد أن الدرجة العالية تعني أن السلوك صحيح: الاختبار الذي يتوقع قيمة خاطئة يقتل الطفرات أيضاً، كما رأيت في تجربة مقال الذكاء الاصطناعي، فاختبار الطفرات يقيس هل تلاحظ الاختبارات التغيير، وليس هل القيمة المتوقعة صحيحة.

قائمة تحقق

  • الأداة تعمل فعلاً: طفرة واضحة على فرع مختبر قتلت، ومتوسط الاختبارات لكل طفرة أكبر من صفر.
  • إصدارات الأداة والمشغل وإطار الاختبار مثبتة، ومتوافقة بحسب وثائق المشغل.
  • طلب الدمج يشغل الطفرات على الملفات التي تغيرت فقط، مع الوضع التدريجي حيث يتوفر.
  • تشغيل كامل دوري، ليلاً أو أسبوعياً، مع --force في StrykerJS حتى لا تتراكم نتائج قديمة.
  • الحد الأدنى break أو mutationThreshold يبدأ من الدرجة الحالية ويرتفع مع الوقت.
  • كل طفرة ناجية تنتهي إما باختبار جديد عند الحد، وإما باستثناء على سطر واحد مع سبب مكتوب، وإما بحذف كود زائد.
  • الاختبارات تتحقق من قيم دقيقة ومن رسائل الأخطاء، ومن كل حد في المتطلب من الجهتين.
  • لا تعتمد على أداة واحدة لكل شيء إذا كان الكود حساساً، فمجموعات العوامل تختلف كما رأيت.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • التغطية تخبرك أن السطر نفذ، واختبار الطفرات يخبرك هل يفشل اختبار إذا تغير السطر، وفي التجربة بقيت التغطية 100% بينما تحركت درجة الطفرات من 77.27% إلى 95.45% في StrykerJS ومن 64% إلى 91% في PIT.
  • الدرجة هي المكتشفة، أي Killed وTimeout، مقسومة على الطفرات الصالحة، والطفرات غير المغطاة تحسب ضدك، والمستثناة والتي لا تترجم لا تحسب.
  • الطفرة المكافئة لا يمكن قتلها، وقد ظهرت عند حد 5 كغ في الأدوات الثلاث، فاستثنها على سطر واحد مع السبب، ولا تكتب لها اختبارات عبثية.
  • التكلفة عدد الطفرات في زمن الاختبارات، فشغل الطفرات على ما تغير في طلب الدمج، واستخدم التحليل لكل اختبار والوضع التدريجي، واترك التشغيل الكامل لليل.
  • الأدوات تختلف في عواملها: StrykerJS أنتجت 22 طفرة وPIT 11 وcosmic-ray 55، وcosmic-ray وحدها كشفت أننا حذفنا اختبار الطلب فوق 200.
  • تحقق أن الأداة تعمل قبل أن تثق برقمها، فدرجة صفر مع Vitest 5 كانت مشكلة توافق وليست اختبارات سيئة.

وفي هذه المرحلة من خارطة الطريق سوف نتحدث بإذن الله عن اختبار العقود Contract Testing بين الخدمات، وعن اختبار سهولة الوصول Accessibility، وعن اختبار تطبيقات الجوال.

دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلاختبارات يكتبها الذكاء الاصطناعي: متى تثق بها؟أين يساعدك الذكاء الاصطناعي في كتابة الاختبارات وأين يخدعك، ولماذا يبقى قرار ما هو الصحيح بيد الإنسان.دليلما هي جودة الكود Code Quality وكيف تقيسها؟ما الذي يجعل الكود سهل التغيير، وماذا تقيس أدوات الفحص فعلاً، ولماذا يحتاج كود الوكلاء الأذكياء إلى بوابة جودة.دليلأدوات جودة الكود: أيها تختار لفريقك؟مقارنة عملية بين مدققات كل لغة والأدوات الجامعة ومنصات الجودة، بتراخيصها وأسعارها ونتائجها على مستودع واحد.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Docker Engine 29.8.0 مع StrykerJS 10.0.0 و@stryker-mutator/vitest-runner 10.0.0 وVitest 4.1.11 (وتجربة Vitest 5.0.4 التي أعطت صفراً) وTypeScript 7.0.2 على Node.js 24.21.0 (mcr.microsoft.com/playwright:v1.64.0-noble)، وPIT 1.30.0 مع pitest-junit5-plugin 1.2.3 وJUnit 6.1.3 وJaCoCo 0.8.15 على Maven 3.10.0 وJava 21.0.12.1 (maven:3.10.0-eclipse-temurin-21-alpine)، وcosmic-ray 8.7.0 مع pytest 9.1.1 وcoverage 7.16.2 على Python 3.13.16 (python:3.13.16-slim).
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.