الاختبار الغامض.
الاختبار الغامض هو اختبار لا يمكن للقارئ أن يحدد من خلال طريقة الاختبار بمفردها ما هو السيناريو المجهز، وما هو السلوك الذي تم تشغيله، وما هي النتيجة المتوقعة — لأن الغرض مدفون في الكثير من التفاصيل، أو قلة السياق، أو منطق مخفي في مكان آخر.
##العلامات والأعراض
تقرأ اختباراً ولا تزال لا تستطيع الإجابة على ثلاثة أسئلة: ما هي التهيئة، وما الذي يتم تشغيله، وما هي النتيجة المتوقعة؟ سلسلة السبب والنتيجة مخفية. يقسم ميسزاروس الأسباب إلى "معلومات كثيرة جداً" و"معلومات قليلة جداً". العلامات الشائعة:
- الاختبار المتلهف (Eager Test) — طريقة اختبار واحدة تتحقق من سلوكيات متعددة غير مترابطة، لذا لا يوجد هدف واحد واضح.
- الضيف الغامض (Mystery Guest) — يعيش التجهيز أو القيم المتوقعة خارج الاختبار (ملف، أو سجل قاعدة بيانات مشترك، أو تجهيز يتم تحميله بالاسم)، لذا لا يمكنك معرفة سبب نجاح التحقق.
- التجهيز العام (General Fixture) — يقوم خطاف
beforeEach/مصنع مشترك كبير ببناء ما هو أكثر بكثير مما يحتاجه هذا الاختبار; وتضيع الحقول القليلة ذات الصلة في الضوضاء. - المعلومات غير ذات الصلة (Irrelevant Information) — صفحات من بيانات التهيئة أو لقطة شاشة (snapshot) عملاقة حيث لا يهم سوى حقل أو حقلين فقط. يتناوله بالتفصيل مقال الاختبار المسهب.
- بيانات اختبار ثابتة ومكتوبة يدوياً (Hard-Coded Test Data) — قيم ثابتة سحرية (مثل
42، أو"a3f9-..."، أوuserId=7) دون معنى مسمى، وتتكرر عبر التهيئة والتحققات. انظر اختبار الأرقام السحرية. - الاختبار غير المباشر (Indirect Testing) — يقوم الاختبار بنكز النظام تحت الاختبار من خلال عدة كائنات أخرى، مما يحجب ما هو قيد الاختبار بالفعل.
// غامض: متلهف + ضيف غامض + معلومات غير ذات صلة
test('user', async () => {
const data = loadFixture('users.json'); // ضيف غامض: القيم تعيش في ملف
const svc = new UserService(data, cfg, clock); // تجهيز عام: معظم هذا غير مستخدم
const u = await svc.create({ ...data[0], roles: ['a','b'], flags: 0x1f });
expect(u.id).toBeDefined();
expect(u.email).toContain('@');
expect(await svc.count()).toBe(data.length + 1); // لماذا هذا الرقم؟ الإجابة موجودة في الملف
expect(await svc.login(u.email, 'p@ss')).toBe(true); // سلوك ثانٍ غير ذي صلة
});
هنا لا يمكنك معرفة ما يثبته الاختبار دون فتح ملف users.json، وهو يتحقق فعلياً من الإنشاء و تسجيل الدخول في وقت واحد.
##أسباب المشكلة
لماذا يحدث ذلك
- تنمو الاختبارات بالتراكم: يضيف المطور "تحققاً إضافياً" إلى اختبار موجود بدلاً من كتابة اختبار جديد، مما ينتج عنه اختبار متلهف.
- تبدو مشاركة التهيئة فعالة، لذا يُعاد استخدام تجهيز واحد واسع النطاق أو
beforeEachفي كل مكان (التجهيز العام)، ويتم تحميل ملفات خارجية/بذور قاعدة بيانات بالاسم (الضيف الغامض). - يؤدي نسخ ولصق بيانات تبدو واقعية إلى سحب العشرات من الحقول غير ذات الصلة والأرقام السحرية.
- المحاكاة المفرطة أو المرور عبر العديد من المتعاونين يحول اختبار الوحدة إلى اختبار غير مباشر (Indirect Testing).
لماذا يضر ذلك
- سهولة القراءة: الاختبار هو توثيق للسلوك المقصود. إذا لم يتمكن القارئ من إعادة بناء الترتيب ← التشغيل ← التحقق، فإن قيمة هذا التوثيق تضيع.
- سهولة الصيانة: عندما يفشل اختبار غامض، فإنك لا تعرف أي سلوك قد تعطل أو ما إذا كان الخطأ في الاختبار أم في الكود، لذا تكون التغييرات بطيئة ومحفوفة بالمخاطر.
- الموثوقية: يقرن الضيف الغامض والتجهيز العام الاختبار بالحالة الخارجية/المشتركة، مما يتسبب في إخفاقات متقلبة أو معتمدة على الترتيب (ويكسر خاصية "الجدة والحتمية" لاختبار الوحدة الجيد).
- الثقة الزائفة: تحجب الاختبارات المتلهفة التغطية الحقيقية — حيث يؤدي الفشل المبكر في الطريقة إلى إيقاف الفحوصات اللاحقة، لذا فإن السلوكيات التي تعتقد أنها مختبرة قد لا تعمل أبداً. وكما يلاحظ ميسزاروس، فإن أخطاء البرمجة يسهل إخفاؤها في الاختبارات الغامضة، مما ينتج اختبارات معطوبة (Buggy Tests) تنجح لأسباب خاطئة.
##المعالجة
لا يوجد علاج واحد للاختبار الغامض، لأن "الغموض" عَرَض له أسباب متعددة. شخّص أولًا، ثم طبّق العلاج المناسب للسبب الذي وجدته، فلكل سبب مقاله الخاص الذي يشرح إعادة الهيكلة بالتفصيل.
- اقرأ الاختبار قراءة باردة. اطلب من شخص لم يرَ الكود أن يحدد، من جسم الاختبار وحده، الإعداد والإجراء والنتيجة المتوقعة. حيثما اضطر إلى فتح ملف آخر أو التخمين، فهناك يكمن السبب.
- عدة سلوكيات غير مترابطة في اختبار واحد؟ هذا هو الاختبار المتلهف: قسّمه إلى اختبارات مركّزة يحمل كل منها اسم السلوك الذي يتحقق منه. وبذلك لا يعود فشلٌ مبكر يخفي التحققات اللاحقة.
- قيم متوقعة لا يبررها إلا ملف خارجي أو بيانات تهيئة أو سجل مشترك؟ هذا هو الضيف الغامض: انقل إلى الاختبار البيانات التي يعتمد عليها التحقق.
- خطاف
beforeEachمشترك وواسع يبني أكثر بكثير مما يحتاجه الاختبار؟ هذا هو التجهيز العام: انتقل إلى تجهيز أدنى وجديد لكل اختبار. - صفحات من الإعداد غير ذي الصلة أو تحققات حقلًا بحقل؟ هذا هو الاختبار المسهب: أخفِ الضوضاء خلف دوال الإنشاء والبُناة (builders) وقارن بكائن متوقع، والآلية مشروحة هناك.
- قيم حرفية بلا تفسير؟ انظر اختبار الأرقام السحرية: سمِّ القيم كي يشرح التحقق نفسه بنفسه.
- لا يُوصَل إلى النظام الخاضع للاختبار إلا عبر عدة كائنات أخرى (الاختبار غير المباشر)؟ اختبره عبر واجهته الخاصة، أو استخرج المنطق كي يمكن اختباره مباشرة.
// قبل (غامض)
test('user', async () => {
const data = loadFixture('users.json');
const svc = new UserService(data, cfg, clock);
const u = await svc.create({ ...data[0], roles: ['a','b'], flags: 0x1f });
expect(await svc.count()).toBe(data.length + 1);
});
// بعد: غرض واحد، تجهيز مضمن، بيانات مسماة
test('create() persists a new user', async () => {
const svc = userServiceWith([]); // تجهيز أدنى وجديد
const newUser = aUser({ email: '[email protected]' }); // يضع البناء (builder) قيماً افتراضية للضوضاء
const created = await svc.create(newUser);
expect(created.email).toBe('[email protected]');
expect(await svc.count()).toBe(1); // يفسر نفسه بنفسه، لا يوجد ملف خارجي
});
احرص على اتباع هيكل AAA (الترتيب والتنفيذ والتحقق): يجب أن يستوعب القارئ السيناريو دون مغادرة طريقة الاختبار.
##أدوات الكشف
- eslint-jest jest/max-expects — يحدّ من عدد استدعاءات expect() في الاختبار الواحد
- eslint-vitest vitest/max-expects — يحدّ من عدد استدعاءات expect() في الاختبار الواحد
- eslint-jest jest/no-large-snapshots — يمنع اللقطات (snapshots) الكبيرة
- eslint-vitest vitest/no-large-snapshots — يمنع اللقطات (snapshots) الكبيرة
- eslint-jest jest/max-nested-describe — يحدّ من عمق تداخل كتل describe()