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

يُسهم توليد الاختبارات تلقائيًا في تحسين استقرار المشروع من خلال محاكاة تدفقات الاستخدام المختلفة، واكتشاف الثغرات الناتجة عن التفاعلات الداخلية بين الوحدات. ومع توسع الكود، يصبح الاعتماد على هذا الأسلوب أداة أساسية لضمان الجودة وتقليل التكاليف الناتجة عن إصلاح الأخطاء المتأخرة.
يساعد هذا النهج أيضًا على رفع مستوى الثقة في الشيفرة، خاصةً عند تنفيذ تحديثات كبيرة أو إعادة بناء أجزاء رئيسية منها، إذ يتيح كشف الأخطاء المحتملة بسرعة قبل الوصول إلى بيئة الإنتاج.
هل سبق لك أن رأيتَ ظهور “NaN” على موقع ويب ذي ترميز سيء؟ أو حيرتَ نفسك بسبب خطأ برمجي مستمر، لتكتشف لاحقًا أنه مجرد خلل في اللغة؟ تكمن المشكلة جزئيًا في افتراضاتك، وربما في حزمة اختبارات غير مكتملة. سأشرح ما هو الاختبار القائم على الخصائص (PBT) وكيف يحل هذه المشاكل.
ما هو الاختبار القائم على الخصائص (PBT)؟
على مستوى عالٍ جدًا، يُدخل آلاف القيم العشوائية في الاختبارات، مما يُنشئ آلاف الاختبارات في هذه العملية. يُقارن الاختبار القائم على الخصائص (PBT) الاختبار القائم على الأمثلة، الذي يُقارن النتائج ببعض القيم المعروفة.
لنلقِ نظرة على “الاختبار القائم على الأمثلة” باستخدام جافا سكريبت:
// Node.js
test("adds two numbers", () => {
assert(add(1, 2) == 3);
});
النهج البسيط هو افتراض أن حالة الاختبار هذه تغطي كل شيء. ولكن لكي نكون أكثر شمولاً، يمكننا أيضًا اختبار الأعداد السالبة، والأصفار، والأعداد العشرية، وأصغر وأكبر عددين صحيحين آمنين (Number.MIN_SAFE_INTEGER وNumber.MAX_SAFE_INTEGER)، والقيم خارج هذا النطاق. للحصول على شيفرة برمجية متينة، يمكننا حتى استخدام أي نوع بيانات يمكن تصوره والتحقق من أنه يتعامل معها بسلاسة عن طريق طرح خطأ. وهذا عبء يستغرق وقتًا طويلاً.
بعد كتابة عشرات الاختبارات، قد تتوصل إلى شيء مثل هذا:
function add(a, b) {
if (typeof a !== "number") throw Error();
if (typeof b !== "number") throw Error();
return a + b;
}
يبدو هذا معقولاً، أليس كذلك؟ باستثناء:
typeof NaN === "number"; // -> true
لذا، سيُسرّع ذلك. لنُحسّنه.
function add(a, b) {
if (typeof a !== "number") throw Error();
if (typeof b !== "number") throw Error();
if (a === NaN) throw Error();
if (b === NaN) throw Error();
return a + b;
}
بالتأكيد الآن…
add(NaN, 1);
// "fc.assert()" runs this function many times.
// Each generator produces one random value per execution.
fc.property(
...arbitraries // Aka generators. Functions that produce a random value.
(...args) => {
// A predicate function. Return true to pass and false to fail.
// You can alternatively use expect() here.
// The generators inject a random value into each "arg."
};
);
test("throws for objects", () => {
fc.assert(
fc.property(fc.integer(), fc.object(), (a, b) => {
expect(() => add(a, b)).toThrow();
return true; // Otherwise we return undefined, which is falsy.
}),
);
});
function add(a, b) {
if (typeof a !== "number") throw Error();
if (typeof b !== "number") throw Error();
return a + b;
}
// A simple function that returns our generator.
function kitchenSink() {
// The generated value will be one of NaN or anything (except a number).
return fc.oneof(
fc.constant(NaN),
// Everything except numbers.
fc.anything().filter((t) => typeof t !== "number"),
);
}
test("everything and the kitchen sink", () => {
fc.assert(
fc.property(kitchenSink(), kitchenSink(), (a, b) => {
expect(() => add(a, b)).toThrow();
return true;
}),
);
});
function add(a, b) {
if (typeof a !== "number") throw Error();
if (typeof b !== "number") throw Error();
if (isNaN(a)) throw Error();
if (isNaN(b)) throw Error();
return a + b;
}
test("always returns a number or throws", () => {
fc.assert(
fc.property(fc.anything(), fc.anything(), (a, b) => {
try {
// Number.isFinite() returns false for all non-numbers.
// Returning false fails the test.
// Therefore, this test fails if the "add" returns a non-number.
return Number.isFinite(add(a, b));
} catch (e) {
// We expect "add" to throw an error; it's receiving garbage.
return true;
}
}),
// I've set 10,000 tests (up from the default 100).
// More tests mean more chance of catching subtle bugs.
{ numRuns: 10000 },
);
});

هذا أمر جيد. هذا يعني أننا نكتشف الأخطاء الآن، بدلاً من ملء صفحات الويب ببيانات غير متوفرة. تغيير أخير.
ملاحظة
نظرًا لأن PBT يُلقي ببيانات عشوائية على الكود، فإنه لا يكشف عن أي مشاكل في كل مرة يُجرى فيها اختبار. قد يكون اكتشاف الأخطاء متقطعًا، وزيادة عدد مرات إجراء الاختبار يُحسّن من اختبار الكود.
function add(a, b) {
if (!Number.isFinite(a)) throw new Error();
if (!Number.isFinite(b)) throw new Error();
return a + b;
}
إذا كنتَ عاقلاً، فستتوقع أن يعمل الكود البرمجي العاقل، لكن جافا سكريبت مُختلّة. حتى بايثون قد تحتوي على عيوب غير متوقعة. بالإضافة إلى ذلك، قد تُزيل اللغات ذات الكتابة القوية فئة كاملة من الأخطاء البرمجية، لكنها لا تُزيل أخطاء البرمجة، أو الأخطاء في المواصفات، أو الافتراضات السخيفة. إذا كنتَ ترغب في كود برمجي قوي، فعليك إخضاعه لاختبارات مكثفة، والاختبار القائم على الأمثلة لا يكفي.
توفر بايثون مكتبة PBT رائعة تُسمى Hypothesis، وتُوفر Golang مكتبة Rapid. إذا كنتَ تكتب كودًا برمجيًا عامًا أو تستخدم لغات برمجة غير موثوقة، فإنني أوصي بشدة باستخدام PBT.
يساعد الاعتماد على الاختبارات التلقائية الواسعة في رفع جودة الشيفرة بشكل واضح، إذ يتيح اكتشاف الأخطاء التي لا تظهر أثناء الاختبار التقليدي. ومع استمرار توسع المشاريع البرمجية، يصبح هذا الأسلوب خطوة أساسية لضمان الأداء والاستقرار.
يمكن البدء بتطبيق هذه التقنية تدريجيًا داخل المشروع لرفع مستوى الجودة وتقليل المشكلات المستقبلية، مع الاستفادة من ميزة الكشف المبكر عن الأخطاء قبل وصولها إلى المستخدم النهائي.

