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

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

الـ Coverage وحدودها

18 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

تقيس الـ coverage (نسبة التغطية) في Vitest، وتقرأ التقرير لتجد الكود غير المختبَر، وتشرح لماذا قد يُخفي رقم تغطية مرتفع مجموعة اختبارات ضعيفة.

  1. 1تشغيلها
  2. 2الأرقام الأربعة
  3. 3قراءة التقرير
  4. 4ما لا تستطيع الـ coverage رؤيته
  5. 5كم يكفي؟

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

كل فريق يضيف اختبارات يسأل في النهاية “كم يكفي؟”، والـ coverage هي الرقم الذي يبدو أنه يجيب. لكنها تجيب عن سؤال أضيق مما يظنّه الناس. إن استُخدمت جيدًا، فهي طريقة سريعة لإيجاد كود لم يختبره أحد. وإن استُخدمت كدرجة تقييم، فهي تكافئ الاختبارات الخاطئة.

تشغيلها

تدعم Vitest مزوّدَين للـ coverage. الافتراضي، v8، يستخدم التغطية المدمجة في محرّك JavaScript ولا يحتاج إلى خطوة تجهيز للكود (instrumentation)؛ أما istanbul فيجهّز الكود ويعمل في بيئات تشغيل أكثر. ثبّت المزوّد وشغّل مع --coverage:

Terminal
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage

اضبط ما تقيسه وطريقة الإبلاغ عنه:

vite.config.js
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['src/**/*.{js,jsx}'],
      exclude: ['src/**/*.test.{js,jsx}', 'src/main.jsx'],
      reporter: ['text', 'html'],
      thresholds: { lines: 80, branches: 75 },
    },
  },
});

include مهم: في Vitest 4، بدونه لا يسرد التقرير إلا الملفات التي صادف أن حمّلتها اختباراتك، فلا تظهر الوحدة غير المختبَرة كليًا أصلًا، وتبدو النسبة أفضل مما هي. ومع include، تظهر الملفات غير المختبَرة بنسبة 0%. ويكتب مُبلّغ html الملف coverage/index.html، الذي تفتحه في المتصفّح.

الأرقام الأربعة

  • Statements (العبارات): كم عبارة عملت.
  • Branches (الفروع): كم جهة من كل قرار عملت (if/else، و? :، و&&، و||، و??، والمعاملات الافتراضية).
  • Functions (الدوال): كم دالة استُدعيت مرة واحدة على الأقل.
  • Lines (الأسطر): كم سطرًا عمل فيه شيء ما.

تغطية الفروع هي ما أنظر إليه. const share = people > 0 ? total / people : 0; سطر واحد وفرعان. اختبار بثلاثة أشخاص يجعل السطر مغطّى بنسبة 100% والفروع بنسبة 50%، والفرع غير المختبَر هو بالضبط الحالة الحدّية من القسم 2.

قراءة التقرير

يعلّم تقرير HTML الأسطر التي لم تعمل قط بالأحمر، والفروع التي سُلكت في اتجاه واحد فقط بالأصفر. وهذا هو الجزء الثمين: الكود الأحمر غير مختبَر، بكل تأكيد. لا يمكن لأي assertion في أي مكان أن يكون قد فحصه، لأنه لم يعمل قط. حين أنضمّ إلى مشروع، يكون تقرير الـ coverage أسرع خريطة لأماكن الخطر: معالجة الأخطاء غير المغطّاة في وحدة الدفع، وفرع else الذي لم يصل إليه أحد.

صندوق كبير لكل الكود؛ بداخله صندوق للكود الذي نفّذته الاختبارات؛ وبداخل ذلك صندوق أصغر للكود الذي تتحقّق الاختبارات فعلًا من نتائجه. تُبلّغ الـ coverage عن الصندوق الأوسط. كل الكود نفّذته الاختبارات ما تُبلّغ عنه الـ coverage نتائج مُتحقَّق منها ما تحتاج إليه
ترى الـ coverage ما عمل، لا ما فُحص

ما لا تستطيع الـ coverage رؤيته

تسجّل الـ coverage التنفيذ. ولا تعرف شيئًا عمّا إذا كان أي شيء قد فحص النتيجة. هذا الاختبار يجعل computeBalances مغطّاة بنسبة 100%:

JavaScript
test('computes balances', () => {
  const balances = computeBalances([
    { paidBy: 'Ana', amountCents: 3000, splitAmong: ['Ana', 'Ben', 'Cai'] },
  ]);
  expect(Object.keys(balances)).toHaveLength(3);
});

عمل كل سطر. وسينجح الـ assertion حتى لو خُصم المبلغ من الدافع بدل أن يُضاف إليه، أو أُخذ الجزء الصحيح من الحصص فاختفى سنت، أو استبدل مصروف ثانٍ الأول. الـ coverage: 100%. الثقة: قريبة من الصفر. ويبدأ التمرين من هذا الاختبار بالضبط.

السطر غير المغطّى سؤال، لا أمر. أحيانًا تكون الإجابة الصحيحة اختبارًا. وأحيانًا تكون حذف الكود: فرع لا يستطيع أحد الوصول إليه، أو بديل احتياطي لمتصفّح توقّفت عن دعمه، أو دالة مساعدة لم يعد شيء يستدعيها. والكود الميت “المغطّى” باختبار كُتب فقط لرفع الرقم هو أسوأ النتائج، لأنه يبدو الآن مهمًّا.

كم يكفي؟

لا يوجد رقم عالمي. لكن هذه قواعد تقريبية مفيدة:

  • استهدف تغطية فروع عالية للمنطق الأساسي (المال، والصلاحيات، وتحويلات البيانات)، واقبل بأقل لكود الربط وتوصيلات الواجهة التي تشغّلها اختبارات الـ end-to-end على أي حال.
  • استثنِ الكود الذي لا تكتب له unit tests عن قصد (نقاط الدخول، والملفات المولّدة، والوحدات التي تحتوي أنواعًا فقط) كي يكون للرقم معنى.
  • انظر إلى الـ coverage على الـ diff في مراجعة الكود: هل أضاف هذا التغيير فروعًا غير مختبَرة؟ وهذا أنفع عمليًا من نسبة على مستوى المشروع كله.

تخبرك الـ coverage أين تنقص الاختبارات. ولتعرف إن كانت الاختبارات الموجودة جيدة أصلًا، تحتاج إلى أداة مختلفة، وهي موضوع الدرس القادم. لكن أولًا، في التمرين، حوّل مجموعة تغطيتها 100% ولا تلتقط شيئًا إلى مجموعة تلتقط أخطاءً حقيقية.

أهم النقاط

  • تقيس الـ coverage أيّ العبارات والفروع والدوال والأسطر عملت أثناء الاختبارات، لا إن كان شيء قد فحص النتائج.
  • أنفع ما في تقرير الـ coverage هو الأحمر والأصفر اللذان يُظهرهما لك؛ فالكود غير المغطّى كود غير مختبَر بكل تأكيد.
  • تغطية الفروع (branch coverage) أكثر صرامة وأنفع من تغطية الأسطر، لأن السطر الواحد قد يحمل عدة قرارات.
  • استخدم الحدود الدنيا (thresholds) كأرضية تمنع التغطية من الانزلاق، لا كهدف تسعى إليه؛ فالرقم الذي يصبح هدفًا يُتلاعب به.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يستدعي اختبار computeBalances(expenses) ولا يؤكّد إلا expect(result).toBeDefined(). الـ coverage للملف 100%. ماذا يخبرك ذلك؟

السؤال 2 من 3

تغطية الأسطر 100% لكن تغطية الفروع 50% للسطر const share = people > 0 ? total / people : 0;. ما الناقص؟

السؤال 3 من 3

يضع فريقك حدًّا أدنى صارمًا للـ coverage قدره 90% ويجعله هدفًا ربع سنوي. ما الأثر الجانبي الأرجح؟

تدريب

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

من تغطية 100% إلى ثقة حقيقية

يشغّل الاختبار المبدئي كل سطر في computeBalances (وsplitEvenly)، فسيُظهر تقرير الـ coverage نسبة 100%. ومع ذلك لا يفحص إلا أن ثلاثة أسماء عادت.

قوِّ المجموعة حتى تلتقط ثلاثة أخطاء مخفيّة تفوت مجموعة الـ 100%: نسخة تخصم من الدافع بدل أن تضيف إليه، ونسخة تأخذ الجزء الصحيح من كل حصة فتختفي السنتات المتبقّية (فلا يعود مجموع الأرصدة صفرًا)، ونسخة تستبدل رصيد الدافع بدل أن تضيف إليه حين يدفع مرتين. القواعد مذكورة في تعليق الـ subject.

function splitEvenly(totalCents, people) {
  if (!Number.isInteger(people) || people < 1) {
    throw new RangeError('people must be a positive integer');
  }
  const base = Math.floor(totalCents / people);
  const remainder = totalCents % people;
  return Array.from({ length: people }, (_, i) => (i < remainder ? base + 1 : base));
}

// Computes each person's balance in cents from a list of expenses.
// Positive: the group owes them. Negative: they owe the group.
// Each expense is { paidBy, amountCents, splitAmong: [names] }.
// - The payer is credited the full amount.
// - Each person in splitAmong is debited their share from splitEvenly
//   (leftover cents go to the first names in the list).
// - Balances accumulate across expenses and always add up to zero.
function computeBalances(expenses) {
  const balances = {};
  for (const { paidBy, amountCents, splitAmong } of expenses) {
    const shares = splitEvenly(amountCents, splitAmong.length);
    balances[paidBy] = (balances[paidBy] ?? 0) + amountCents;
    splitAmong.forEach((name, i) => {
      balances[name] = (balances[name] ?? 0) - shares[i];
    });
  }
  return balances;
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على computeBalances الحقيقية
  • تلتقط النسخة التي تخصم من الدافع
  • تلتقط النسخة التي تضيّع السنتات المتبقّية
  • تلتقط النسخة التي تستبدل رصيد الدافع