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

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

اختبر السلوك لا التفاصيل الداخلية

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

الدرس في لمحة

تكتب اختبارات تثبّت عقد الدالة (المدخلات والمخرجات والثوابت) كي تصمد أمام إعادة الهيكلة ولا تفشل إلا حين يتغيّر السلوك فعلًا.

  1. 1السلوك مقابل التفاصيل الداخلية
  2. 2كيف تبدو الاختبارات المرتبطة بالتفاصيل الداخلية
  3. 3اختبار العقد بدلًا من ذلك
  4. 4ما مدى الدقّة المطلوبة في الـ assertions؟
  5. 5حين تكسر إعادة الهيكلة اختبارًا فعلًا

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

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

السلوك مقابل التفاصيل الداخلية

إليك splitEvenly في Splitwise-lite، التي تقسّم فاتورة بالسنتات بين الأشخاص. تذهب السنتات المتبقّية إلى أوائل الأشخاص في القائمة، فتكون النتيجة عادلة (لا تختلف الحصص بأكثر من سنت واحد) وحتمية:

JavaScript
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));
}

console.log(splitEvenly(1000, 3)); // $10.00 three ways
console.log(splitEvenly(1001, 4));
console.log(splitEvenly(2, 3));

سلوكها هو كل ما يستطيع المستدعي ملاحظته: المصفوفة التي تعيدها، والخطأ الذي ترميه حين يكون عدد الأشخاص صفرًا. أما تفاصيلها الداخلية فهي الطريقة: Math.floor، والعامل %، وArray.from، وأسماء المتغيّرات. تستطيع إعادة كتابة جسم الدالة بحلقة، أو بتوزيع السنتات واحدًا تلو الآخر، دون أن يلاحظ أيّ مستدعٍ شيئًا.

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

ترسل الاختبارات مدخلات إلى splitEvenly وتفحص المخرجات؛ والدوال المساعدة الداخلية داخل المربّع المتقطّع لا تلمسها الاختبارات. الاختبارات مدخلات، مخرجات splitEvenly(total, people) الداخل: floor و% وArray.from حرّ في التغيّر
الاختبارات تخاطب العقد العام، لا الأجزاء الداخلية

كيف تبدو الاختبارات المرتبطة بالتفاصيل الداخلية

هذه أنماط حقيقية حذفتها بنفسي من قواعد كود:

JavaScript
// Spying on Math.floor: breaks if you switch to integer division another way
const floor = vi.spyOn(Math, 'floor');
splitEvenly(1000, 3);
expect(floor).toHaveBeenCalledWith(1000 / 3);

// Testing an exported private helper: breaks when you inline or rename it
expect(_remainderFor(1000, 3)).toBe(1);

// Snapshotting the source-shaped output of a debug helper nobody calls
expect(describeSplit(1000, 3)).toMatchSnapshot();

كل واحد منها ينجح اليوم ولا يخبرك بشيء لا يخبرك به اختبار السلوك. وكل واحد منها سيفشل في اليوم الذي يحسّن فيه أحدهم الكود.

اختبار العقد بدلًا من ذلك

ابدأ مما يعتمد عليه المستدعي. بالنسبة إلى splitEvenly فهو:

  1. حصة واحدة لكل شخص.
  2. مجموع الحصص يساوي الإجمالي. لا يضيع سنت، ولا يُختلَق سنت.
  3. لا تختلف الحصص بأكثر من سنت واحد.
  4. تذهب السنتات الإضافية إلى أوائل الأشخاص (وهذا موثّق، فقد يعتمد عليه المستدعون).
  5. عدد الأشخاص صفر خطأ.

النقطتان 2 و3 ثوابت (invariants): تصحّ مع كل مدخل صالح، لذا تستطيع فحصها على عدة مدخلات دفعة واحدة:

src/split.test.js
import { describe, test, expect } from 'vitest';
import { splitEvenly } from './split.js';

const sum = (xs) => xs.reduce((a, b) => a + b, 0);

describe('splitEvenly', () => {
  test('never loses or invents a cent', () => {
    for (const [total, people] of [[1000, 3], [1001, 4], [2, 3], [0, 5]]) {
      expect(sum(splitEvenly(total, people))).toBe(total);
    }
  });

  test('gives the leftover cents to the first people', () => {
    expect(splitEvenly(1001, 4)).toEqual([251, 250, 250, 250]);
  });

  test('refuses to split between zero people', () => {
    expect(() => splitEvenly(1000, 0)).toThrow(RangeError);
  });
});

ثابت المجموع هو أثمن سطر في الملف. النسخة التي تُسقط الباقي تفشل فيه. وكذلك الخطأ بفارق واحد (off-by-one) الذي يوزّع سنتًا زائدًا. لا يهمّه كيف تُحسب الحصص، بل يهمّه فقط أن المال محفوظ.

ما مدى الدقّة المطلوبة في الـ assertions؟

يمكن لاختبارات السلوك أن تبقى دقيقة. toEqual([251, 250, 250, 250]) يثبّت الترتيب الموثّق للسنتات الإضافية، وهذا سلوك لأن المستدعين يعتمدون عليه. السؤال ليس أبدًا “دقيق أم مرن؟” بل “هل سيلاحظ مستخدم أو مستدعٍ لو تغيّر هذا؟” إن كانت الإجابة نعم، فأكّده بدقّة. وإن كانت لا، فلا تؤكّده أصلًا.

حين تكسر إعادة الهيكلة اختبارًا فعلًا

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

في التمرين، اكتب اختبارات سلوك لـ splitEvenly تلتقط أربعة أخطاء واقعية. والدرس القادم عن إيجاد المدخلات التي تكشف أخطاءً كهذه.

أهم النقاط

  • السلوك هو ما يستطيع المستدعي ملاحظته (القيم المعادة، والأخطاء المرمية، والآثار الجانبية الظاهرة)؛ أما التفاصيل الداخلية (implementation) فهي الطريقة التي يصل بها الكود إلى ذلك.
  • الاختبار الذي ينكسر حين تعيد الهيكلة دون تغيير السلوك إنذار كاذب، والإنذارات الكاذبة تعلّم الفريق تجاهل البناء الأحمر.
  • الثوابت (invariants) مثل “مجموع الحصص يساوي الإجمالي” تلتقط عائلات كاملة من الأخطاء بـ assertion واحد.
  • لا تصدّر الدوال المساعدة الخاصة لمجرّد اختبارها؛ اختبرها من خلال الدالة العامة التي تستخدمها.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

تعيد كتابة splitEvenly من Array.from إلى حلقة for عادية. مخرجاتها متطابقة مع كل مدخل. فيتحوّل اختباران إلى الأحمر. ماذا يخبرك ذلك عن هذين الاختبارين؟

السؤال 2 من 3

أيّ assertion واحد سيلتقط أكبر عدد من الأخطاء المختلفة في splitEvenly(totalCents, people)؟

السؤال 3 من 3

يصدّر زميلك _remainderFor(total, people) من split.js كي يختبرها كـ unit test مباشرة. ما الطريقة الأفضل؟

تدريب

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

اختبارات سلوك لـ splitEvenly

يسرد التعليق في الـ subject عقد splitEvenly. اكتب اختبارات لذلك العقد (لا لطريقة كتابة الدالة) حتى تنجح مجموعتك على الكود الحقيقي وتلتقط أربعة أخطاء مخفيّة:

  • نسخة تُسقط السنتات المتبقّية،
  • نسخة توزّع سنتًا متبقّيًا زائدًا،
  • نسخة تعطي السنتات المتبقّية إلى آخر الأشخاص بدل أوّلهم،
  • نسخة تعيد مصفوفة فارغة حين يكون عدد الأشخاص صفرًا بدل أن ترمي خطأً.

الدالة المساعدة sum موجودة مسبقًا في الكود المبدئي.

// Splits a bill in integer cents between people.
// Contract:
//   - one share per person
//   - the shares add up exactly to totalCents
//   - shares differ by at most one cent
//   - leftover cents go to the FIRST people in the list
//   - fewer than one person throws a RangeError
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));
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على splitEvenly الحقيقية
  • تلتقط النسخة التي تُسقط السنتات المتبقّية
  • تلتقط النسخة التي توزّع سنتًا زائدًا
  • تلتقط النسخة التي تعطي السنتات المتبقّية لآخر الأشخاص
  • تلتقط النسخة التي تقبل عدد أشخاص صفرًا