EduFlow
اختبار تطبيقات JavaScript
⁦0%⁩

الوحدة 5 · استراتيجية الاختبار

اختبار الطفرات (Mutation Testing)

20 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

تشرح كيف يقيّم الـ mutation testing مجموعة الاختبارات، وتشغّل StrykerJS مع Vitest على المنطق الأساسي، وتحوّل الـ mutants الناجية إلى الاختبارات المحدّدة التي كانت ناقصة.

  1. 1كيف يعمل
  2. 2تشغيل StrykerJS مع Vitest
  3. 3قراءة الناجين
  4. 4الـ mutants المنتقاة يدويًا مقابل المولّدة
  5. 5أين يناسب

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

ترك لك التمرين الأخير حقيقة مزعجة: قالت الـ coverage إن النسبة 100%، ولم تلتقط المجموعة شيئًا. تحتاج إلى قياس يطرح السؤال الحقيقي: هل ستلاحظ هذه الاختبارات لو كان الكود خاطئًا؟ يجيب الـ mutation testing (اختبار الطفرات) عن هذا السؤال بالتجربة. وقد كنت تفعل ذلك يدويًا أصلًا: كل تمرين في هذه الدورة شغّل اختباراتك على “mutants” (نسخ طافرة). والأداة تفعل الشيء نفسه بشكل منهجي، لكل سطر.

كيف يعمل

  1. تصنع الأداة mutant: نسخة من كودك فيها تغيير صغير واحد، من النوع الذي يزلّ فيه الإنسان. يصبح < هو <=، و+ يصبح -، وMath.min يصبح Math.max، ويصبح شرط ما true، وتُفرَّغ كتلة كود.
  2. تشغّل الاختبارات التي تغطّي ذلك السطر على الـ mutant.
  3. إن فشل أيّ اختبار، يُقتَل الـ mutant: اختباراتك ستلتقط ذلك الخطأ. وإن نجحت كل الاختبارات، فقد نجا: ذلك الخطأ سيصل إلى المستخدمين.

تكرّر هذا مع مئات أو آلاف الـ mutants. ومقياس الطفرات (mutation score) هو نسبة المقتول منها.

يُنتج الكود المصدري ثلاثة mutants. تعمل مجموعة الاختبارات على كل منها: يفشل اختبار مع اثنين فيُقتلان، وتنجح كلها مع الثالث فينجو، مشيرًا إلى اختبار ناقص. settleUp min → max -cents → +cents sum !== 0 → false فشل اختبار: قُتل فشل اختبار: قُتل نجحت كلها: نجا
كل mutant خطأ صغير متعمَّد واحد؛ فإمّا أن تقتله الاختبارات أو تدعه ينجو

تسأل الـ coverage “هل عمل هذا السطر؟” ويسأل الـ mutation testing “لو كان هذا السطر خاطئًا، هل سيلاحظ أحد؟” والسؤال الثاني هو الذي يهمّك.

تشغيل StrykerJS مع Vitest

StrykerJS هي أداة الـ mutation testing الراسخة لـ JavaScript وTypeScript، ولها plugin مشغّل لـ Vitest:

Terminal
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker init   # interactive; or write the config yourself
stryker.config.json
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "testRunner": "vitest",
  "plugins": ["@stryker-mutator/vitest-runner"],
  "mutate": ["src/money/**/*.js", "!src/**/*.test.js"],
  "reporters": ["html", "clear-text", "progress"],
  "thresholds": { "high": 80, "low": 60, "break": 50 },
  "incremental": true
}

يشغّل npx stryker run مجموعتك أولًا مرة واحدة ليعرف أيّ الاختبارات تغطّي أيّ الأسطر، ثم يختبر كل mutant. ويعرض تقرير HTML الكود المصدري مع تعليم كل mutant بأنه قُتل أو نجا. ويُفشل thresholds.break التشغيل إن كان المقياس دونه، ويعيد incremental استخدام النتائج للكود والاختبارات التي لم تتغيّر منذ التشغيل الأخير، وهذا مهم جدًا للسرعة.

قراءة الناجين

الحالات التي ستراها أكثر من غيرها:

  • Killed (مقتول): فشل اختبار. جيد.
  • Survived (ناجٍ): نجح كل اختبار يغطّيه. assertion ناقص أو ضعيف.
  • No coverage (بلا تغطية): لم يشغّل أيّ اختبار ذلك السطر أصلًا. وكانت الـ coverage ستخبرك بهذا أيضًا.
  • Timeout (انتهاء المهلة): سبّب الـ mutant حلقة لا نهائية فانتهت مهلة الاختبارات. ويُحسب على أنه مكتشَف.

الناجون هم الكنز. بالنسبة إلى settleUp، قد يقول التقرير:

Text
[Survived] MethodExpression   src/money/settle-up.js:21
-   const amountCents = Math.min(debtors[d].cents, creditors[c].cents);
+   const amountCents = Math.max(debtors[d].cents, creditors[c].cents);

[Survived] ConditionalExpression   src/money/settle-up.js:4
-   if (sum !== 0) throw new Error('Balances must add up to zero');
+   if (false) throw new Error('Balances must add up to zero');

كل واحد منها تعليمة دقيقة. الأول يقول إن لا اختبار يستخدم مبالغ يدين فيها المدين بمجموع مختلف عمّا يستحقّه الدائن، فيعطي min وmax الإجابة نفسها في كل اختبار. والثاني يقول إن لا اختبار يمرّر مدخلات غير متوازنة أبدًا. اكتب هذين الاختبارين فيُقتل الاثنان معًا.

الـ mutants المنتقاة يدويًا مقابل المولّدة

الـ mutants في تمارين هذه الدورة انتُقيت يدويًا: كل واحد منها يحاكي خطأً يكتبه الناس فعلًا، مثل معامل متبادل الموقع أو await منسيّة. أما mutants الـ Stryker فتُولَّد بشكل منهجي من مجموعة ثابتة من المحوِّرات (mutators)، عادةً عشرات منها لكل دالة، فتجد فجوات لم تكن لتخطر لك، وتُنتج أيضًا بعض الضجيج. كلاهما مفيد. حين تراجع اختبارات زميل، فعادة الانتقاء اليدوي هي النسخة السريعة: اسأل “ما الخطأ الأرجح هنا، وأيّ اختبار سيفشل؟” وحين تريد قياسًا على مستوى وحدة كاملة، دع الأداة تولّد القائمة.

أين يناسب

الـ mutation testing بطيء: مئات الـ mutants، كل منها يشغّل جزءًا من مجموعتك. بضع عادات تُبقيه عمليًا:

  • احصره بـ mutate في الكود الذي تكون أخطاؤه مكلفة: المال، والصلاحيات، والتحليل. لا في كود ربط الواجهة.
  • شغّله تدريجيًا محليًا، ووفق جدول زمني (ليليًا) أو على الملفات المتغيّرة في CI، بدل تشغيله على المستودع كله مع كل push.
  • استخدم التقرير في المراجعة: الـ pull request الذي يضيف منطقًا وmutants ناجية يستحق سؤالًا.

في التمرين تحصل على مجموعة ضعيفة لـ settleUp وقائمة بالناجين، وتكتب الاختبارات التي تقتلهم.

أهم النقاط

  • تصنع أداة الـ mutation testing (اختبار الطفرات) أخطاءً صغيرة متعمَّدة (mutants) في كودك وتعيد تشغيل الاختبارات؛ والـ mutant الذي يُفشل اختبارًا يُقتَل (killed)، والذي لا يُفشل أيّ اختبار ينجو (survived).
  • مقياس الطفرات (mutation score)، أي نسبة الـ mutants المقتولة من مجموعها، يقيس كم من الأخطاء المعقولة ستلتقطها اختباراتك، وهذا ما لا تستطيعه الـ coverage.
  • كل mutant ناجٍ يشير إلى assertion ناقص أو ضعيف؛ اقرأه، ثم اكتب الاختبار الذي يقتله.
  • بعض الـ mutants متكافئة (لا تغيّر السلوك) ولا يمكن قتلها؛ تجاهلها بدل ليّ الاختبارات.
  • الـ mutation testing بطيء، فشغّله على المنطق الأساسي، بشكل تدريجي أو وفق جدول زمني، لا على كل ملف في كل commit.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يبلّغ Stryker بما يلي: ConditionalExpression: if (sum !== 0) → if (false) — Survived. ماذا يخبرك ذلك؟

السؤال 2 من 3

تغطية الأسطر في مجموعتك 100% ومقياس الطفرات 55%. أيّ تلخيص هو الأفضل؟

السؤال 3 من 3

يغيّر mutant التعبير b.cents - a.cents إلى a.cents - b.cents في دالة المقارنة الخاصة بالترتيب، وكل مدخل صالح ما زال يُنتج تسوية صحيحة. ماذا يجب أن تفعل؟

تدريب

بيئة تجريب الكود

اقتل الـ mutants الناجية

تنجح مجموعة settleUp هذه، لكن تقرير Stryker (المنسوخ في تعليق الكود المبدئي) يسرد ثلاثة mutants نجت منها: Math.min تغيّر إلى Math.max، وفحص المجموع الصفري معطّل، وإشارة السالب أُسقطت عند تسجيل ما يدين به المدين. ووجدت مراجعة الكود في الشهر الماضي خطأً رابعًا تفوته المجموعة أيضًا: from وto تبادلا موقعيهما في كل تحويل.

اكتب الاختبارات التي تقتل الأربعة كلها، دون أن تكسر المجموعة على الكود الحقيقي. الـ subject هو settleUp التي بنيتها في درس الـ TDD.

// Returns the transfers that settle a group: [{ from, to, amountCents }].
// balances: { name: cents }, positive = is owed, negative = owes.
// Throws if the balances don't add up to zero.
function settleUp(balances) {
  const entries = Object.entries(balances);
  const sum = entries.reduce((total, [, cents]) => total + cents, 0);
  if (sum !== 0) throw new Error('Balances must add up to zero');

  const debtors = [];
  const creditors = [];
  for (const [name, cents] of entries) {
    if (cents < 0) debtors.push({ name, cents: -cents });
    else if (cents > 0) creditors.push({ name, cents });
  }
  const largestFirst = (a, b) => b.cents - a.cents || a.name.localeCompare(b.name);
  debtors.sort(largestFirst);
  creditors.sort(largestFirst);

  const transfers = [];
  let d = 0;
  let c = 0;
  while (d < debtors.length && c < creditors.length) {
    const amountCents = Math.min(debtors[d].cents, creditors[c].cents);
    transfers.push({ from: debtors[d].name, to: creditors[c].name, amountCents });
    debtors[d].cents -= amountCents;
    creditors[c].cents -= amountCents;
    if (debtors[d].cents === 0) d += 1;
    if (creditors[c].cents === 0) c += 1;
  }
  return transfers;
}
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على settleUp الحقيقية
  • تقتل الـ mutant الذي يحوّل Math.min إلى Math.max
  • تقتل الـ mutant الذي عُطّل فيه فحص المجموع الصفري
  • تقتل الـ mutant الذي يُسقط إشارة السالب للمدينين
  • تقتل الـ mutant الذي يبادل بين from وto