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

الوحدة 2 · اختبارات وحدة لها قيمة

الحالات الحدّية والاختبارات المعتمدة على الجداول

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

الدرس في لمحة

تجد المدخلات التي تكشف الأخطاء باستخدام فئات التكافؤ والقيم الحدّية، ثم تغطّيها بإيجاز باختبارات معتمدة على الجداول عبر test.each.

  1. 1الدالة الخاضعة للاختبار
  2. 2فئات التكافؤ والحدود
  3. 3الاختبارات المعتمدة على الجداول باستخدام test.each
  4. 4من أين تأتي الحالات الحدّية

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

معظم الأخطاء لا تعيش في منتصف نطاق المدخلات. لا ينكسر كود أحد مع “$12.34”. بل ينكسر مع “”، ومع “0”، ومع “12.5” حين تُقرأ اثني عشر دولارًا وخمسة سنتات، ومع " 7.10 " حين تُلصق بمسافة في آخرها. إيجاد هذه المدخلات عن قصد هو المهارة التي يعلّمها هذا الدرس.

الدالة الخاضعة للاختبار

يقبل حقل المبلغ في Splitwise-lite ما يكتبه الناس ويحوّله إلى سنتات صحيحة:

JavaScript
function parseAmount(input) {
  const text = String(input).trim();
  if (!/^\d+(\.\d{1,2})?$/.test(text)) {
    throw new Error(`Not a valid amount: "${input}"`);
  }
  const [whole, fraction = ''] = text.split('.');
  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
  if (cents === 0) throw new Error('Amount must be greater than zero');
  return cents;
}

for (const input of ['12', '12.5', ' 7.10 ', '0.01']) {
  console.log(JSON.stringify(input), '->', parseAmount(input));
}
for (const input of ['', '0', '-5', '12.345', 'ten']) {
  try { parseAmount(input); } catch (e) { console.log(JSON.stringify(input), '->', e.message); }
}

فئات التكافؤ والحدود

لا تستطيع اختبار كل نص ممكن، ولا تحتاج إلى ذلك. تقسيم التكافؤ (equivalence partitioning) يعني جمع المدخلات التي يُفترض أن يعاملها الكود بالطريقة نفسها، واختبار ممثّل واحد من كل مجموعة:

  • أعداد صحيحة: '12'
  • منزلة عشرية واحدة: '12.5' (وهي الخطرة: هل هي 1250 أم 1205؟)
  • منزلتان عشريتان: '12.50'
  • محاطة بمسافات: ' 7.10 '
  • نص غير صالح: 'ten'، ''، '-5'

ثم يضيف تحليل القيم الحدّية (boundary value analysis) المدخلات الواقعة على الحواف بين المجموعات، لأن هناك بالذات يختلط < بـ <=، ويتحوّل {1,2} إلى {1,3}. لكل حدّ، اختبر ما تحته مباشرة، وعليه، وما فوقه مباشرة:

خطّا أرقام. المبلغ: 0 مرفوض، و0.01 أصغر قيمة مقبولة. المنازل العشرية: منزلتان مقبولتان، وثلاث مرفوضة. المبلغ مرفوض مقبول '0' '0.01' العشريات 0 أو 1 أو 2: مقبول 3 فأكثر: مرفوض '12.50' '12.505'
اختبر جهتي كل حدّ، لا المنتصف فقط

الحدود هنا هي الصفر (مرفوض) مقابل سنت واحد (مقبول)، ومنزلتان عشريتان (مقبول) مقابل ثلاث (مرفوض). وإليك قائمة أمرّ عليها مع أي مدخل:

  • الفارغ والمسافات فقط: ''، ' '
  • صفر، وواحد، وكثير: '0'، '0.01'، '12345.67'
  • كل حدّ، من الجهتين: منزلتان مقابل ثلاث
  • أنواع خاطئة: حروف، وإشارة سالب، وفاصلة ('1,000')
  • ضجيج التنسيق: مسافات في البداية أو النهاية، ونقطة في الآخر ('12.')

الاختبارات المعتمدة على الجداول باستخدام test.each

كتابة test(...) منفصل لكل واحد من هذه المدخلات تزحم الملف. يأخذ test.each جدولًا وينتج اختبارًا مسمّى لكل صف:

src/parse-amount.test.js
import { describe, test, expect } from 'vitest';
import { parseAmount } from './parse-amount.js';

describe('parseAmount', () => {
  test.each([
    ['12', 1200],
    ['12.5', 1250],
    ['12.50', 1250],
    ['0.01', 1],
    [' 7.10 ', 710],
  ])('parses %s as %i cents', (input, cents) => {
    expect(parseAmount(input)).toBe(cents);
  });

  test.each(['', '0', '0.00', '-5', '12.505', 'ten', '1,000'])(
    'rejects %s',
    (input) => {
      expect(() => parseAmount(input)).toThrow();
    },
  );
});

يُملأ %s و%i من كل صف، فيقرأ التقرير parses 12.5 as 1250 cents، ويسمّي الفشل المدخل بالضبط. وفي الجداول الأعرض، يمكن أن تكون الصفوف كائنات، ويمكن أن يستخدم الاسم $field:

JavaScript
test.each([
  { input: '12.5', cents: 1250 },
  { input: '0.01', cents: 1 },
])('parses $input as $cents cents', ({ input, cents }) => {
  expect(parseAmount(input)).toBe(cents);
});

المكسب الحقيقي في الصيانة. حين يبلّغ مستخدم أن '12.' يعطّل النموذج، يكون اختبار الانتكاس (regression test) صفًّا جديدًا واحدًا، لا كتلة كود جديدة.

من أين تأتي الحالات الحدّية

لا تجد الحالات الحدّية (edge cases) بالتحديق في الكود. ثلاثة مصادر تقوم بمعظم العمل:

  1. المواصفات، مقروءةً بعين الخصم. “حتى منزلتين عشريتين” تقترح فورًا منزلتين وثلاثًا. “يجب أن يكون موجبًا” تقترح الصفر. كل حدّ في المتطلّبات حدّ يُختبر من جهتيه.
  2. المستخدمون الحقيقيون. يلصق الناس المبالغ من كشوف الحساب البنكية مع مسافات ورموز عملات. ويكتب الناس في ألمانيا الفاصلة كفاصل عشري، فـ '12,50' ليست خطأً مطبعيًا بالنسبة إليهم؛ قرّر إن كنت سترفضها برسالة مفيدة أو تقبلها، واكتب الصف في الحالتين.
  3. تقارير الأخطاء. كل خطأ وصل إلى بيئة الإنتاج حالة حدّية فاتها جدولك. أضف الصف أولًا، وشاهده يفشل، ثم أصلح الكود. هكذا يصبح الجدول سجلًّا لكل ما سار على نحو خاطئ في أي وقت.

كم صفًّا يكفي؟ صف لكل فئة، وجهتا كل حدّ، وصف لكل خطأ سابق. بالنسبة إلى parseAmount هذا نحو خمسة عشر صفًّا، وبضعة أجزاء من الثانية وقت تشغيل، ونوم هانئ جدًا. وإن وجدت نفسك تريد آلاف المدخلات العشوائية، فانظر في مكتبات الاختبار القائم على الخصائص (property-based testing) مثل fast-check، التي تولّد المدخلات وتقلّص حالات الفشل إلى أصغر مثال؛ لكن في معظم أكواد التطبيقات، يكون الجدول المنتقى يدويًا أوضح.

في التمرين، ابنِ جدولًا يكشف أربعة أخطاء تحليل واقعية. وبعده: دوال ترمي أخطاءً ودوال تعيد promises.

أهم النقاط

  • اجمع المدخلات في فئات يُفترض أن يعاملها الكود بالطريقة نفسها، ثم اختبر قيمة نموذجية واحدة من كل فئة، إضافة إلى الحواف بين الفئات.
  • تتجمّع الأخطاء عند الحدود، لذا اختبر ما تحت كل حدّ مباشرة، وعليه، وما فوقه مباشرة (الصفر، والطول الأقصى، وحدّ المنازل العشرية).
  • يحوّل test.each جدولًا من المدخلات والمخرجات المتوقّعة إلى اختبار مسمّى لكل صف، فتصبح إضافة حالة جديدة سطرًا واحدًا.
  • سمِّ كل صف باستخدام عناصر نائبة مثل %s أو $field كي يخبرك الفشل أيّ مدخل انكسر.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

تقبل parseAmount حتى منزلتين عشريتين. أيّ مجموعة مدخلات تختبر هذه القاعدة على أفضل وجه؟

السؤال 2 من 3

ما أسماء الاختبارات التي ينتجها هذا الكود؟

JavaScript
test.each([
  ['12', 1200],
  ['0.5', 50],
])('parses %s as %i cents', (input, cents) => {
  expect(parseAmount(input)).toBe(cents);
});
السؤال 3 من 3

حقل المبلغ في نموذج ما يرفض الصفر. أيّ خطأ يلتقطه اختبار بالمدخل '0' ويفوت المدخلين '1' و'-1'؟

تدريب

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

جدول يكسر parseAmount

تحوّل parseAmount(input) ما كتبه المستخدم إلى سنتات صحيحة. قواعدها مذكورة في تعليق الـ subject.

في الكود المبدئي جدولا test.each ينجحان بالكامل لكنهما لا يلتقطان شيئًا مثيرًا للاهتمام: كل صف يقع بأمان داخل فئة واحدة. أضف صفوفًا (وجداول إن شئت) حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة تقبل ثلاث منازل عشرية، ونسخة تقرأ '12.5' على أنها 1205 سنتًا، ونسخة تقبل الصفر، ونسخة تنسى حذف المسافات.

// Parses what a user typed in the amount field and returns integer cents.
// Accepts: digits, optionally a dot and 1 or 2 decimals, surrounding spaces.
//   "12" -> 1200, "12.5" -> 1250, " 7.10 " -> 710
// Throws an Error for: empty input, zero, negatives, letters,
// commas and more than 2 decimals.
function parseAmount(input) {
  const text = String(input).trim();
  if (!/^\d+(\.\d{1,2})?$/.test(text)) {
    throw new Error(`Not a valid amount: "${input}"`);
  }
  const [whole, fraction = ''] = text.split('.');
  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
  if (cents === 0) throw new Error('Amount must be greater than zero');
  return cents;
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على parseAmount الحقيقية
  • تلتقط النسخة التي تقبل ثلاث منازل عشرية
  • تلتقط النسخة التي تقرأ ‘12.5’ على أنها 1205 سنتًا
  • تلتقط النسخة التي تقبل مبلغًا صفريًا
  • تلتقط النسخة التي لا تحذف المسافات