اختبار تفاصيل التنفيذ.
يقوم الاختبار بالتحقق من كيفية عمل الكود داخلياً — مثل الحقول الخاصة، أو استدعاءات الدوال الداخلية، أو بنية DOM، أو فئات CSS — بدلاً من التحقق من السلوك الملاحظ الذي يعتمد عليه المستخدم الفعلي.
##Signs and Symptoms
تتعرف على هذه المشكلة عندما يتجاوز الاختبار العقد العام (public contract) ويقوم بتثبيت الآلية الداخلية التي تقف خلفه. العلامات الشائعة:
- التحقق من الحالة الخاصة/الداخلية (private/internal state) أو الحقول، أو استدعاء الدوال الخاصة مباشرة (غالباً عبر التحويلات القسرية للأنواع (casts)، أو الانعكاس (reflection)، أو استخدام
// @ts-expect-error). - التجسس (Spying) أو التحقق من استدعاء دالة مساعدة داخلية (مثل
expect(internalCalc).toHaveBeenCalled()) بدلاً من التحقق من النتيجة. - اختبارات واجهة المستخدم التي تستعلم باستخدام فئة الـ CSS، أو الوسم (tag)، أو خطافات
data-*، أو موضع DOM بدلاً من الاستعلام باستخدام الدور (role) أو التسمية (label) أو النص — على سبيل المثال:container.querySelector('.btn-primary > span:nth-child(2)')، أوwrapper.state()، أوwrapper.find('SomeChildComponent').props(). - اختبارات اللقطات (Snapshot tests) لشجرة التصيير بالكامل أو الكائنات الداخلية المتسلسلة، بحيث يفشل الاختبار عند إجراء أي تعديل طفيف في المارك-أب.
- الاختبارات التي تتعطل مع كل عملية إعادة هيكلة على الرغم من أن الميزة لا تزال تعمل بشكل صحيح (السلبيات الزائفة)، وبالعكس تظل ناجحة بعد وجود خلل منطقي لأنها تتحقق فقط من التوصيلات (الإيجابيات الزائفة).
// مشكلة (SMELL): يربط الاختبار بالتفاصيل الداخلية للمكون وبنية الـ DOM
test('counter increments', () => {
const wrapper = mount(<Counter />);
wrapper.instance().handleClick(); // يستدعي دالة خاصة مباشرة
expect(wrapper.state('count')).toBe(1); // يتحقق من الحالة الداخلية
expect(wrapper.find('.count-display').text()).toBe('1'); // محدد CSS هِش
});
يظهر نفس الشكل في جانب الخادم (server-side): مثل expect(service._cache.size).toBe(1) أو التحقق من التسلسل الدقيق للاستدعاءات الداخلية التي تقوم بها الدالة.
##Reasons for the Problem
لماذا يحدث ذلك
- الوصول إلى الحالة الداخلية سهل للغاية — فالحقول العامة، أو الدوال المساعدة المصدرة، أو
container.querySelectorتكون متاحة مباشرة، في حين أن تشغيل السلوك الفعلي يتطلب إعداداً أكثر تعقيداً. - السعي وراء مقاييس التغطية (coverage metrics): فيبدو اختبار كل دالة خاصة بنسبة 1:1 أمراً شاملاً.
- يدفع الاستخدام المكثف للمحاكاة الافتراضية (mocking) المطورين للتحقق مما إذا كان "قد تم استدعاء هذا المساعد" بدلاً من التحقق مما إذا كان "قد حدث الشيء الصحيح".
- الأدوات التي تشجع على ذلك: مثل واجهات برمجة التطبيقات للتصيير السطحي (shallow rendering) أو
instance()أوstate()، أو جلب عقد DOM باستخدام فئة الـ CSS (class name).
لماذا يضر ذلك
- قابلية الصيانة / الهشاشة. هذا ما يسميه "ميسزاروس" بالـ الاختبار الهش (Fragile Test) الناتج عن البرمجيات المحددة بشكل مفرط (Overspecified Software): حيث يقوم الاختبار بتثبيت سلوك لم يطلبه المستخدم أبداً، وبالتالي فإن عمليات إعادة الهيكلة غير الضارة (إعادة تسمية دالة، أو إعادة هيكلة المارك-أب، أو تغيير حقل خاص) تؤدي إلى تعطل الاختبارات الناجحة دون سبب حقيقي. وتصبح الاختبارات عبئاً على عملية إعادة الهيكلة بدلاً من أن تكون شبكة أمان لها.
- الثقة الزائفة (الخطر الأساسي، وفقاً لـ Kent C. Dodds). تفشل اختبارات تفاصيل التنفيذ في كلا الاتجاهين الخاطئين: السلبيات الزائفة (false negatives) (يفشل الاختبار على الرغم من أن الميزة لا تزال تعمل بشكل صحيح) والإيجابيات الزائفة (false positives) (ينجح الاختبار على الرغم من تعطل الميزة — لأنك تحققت من التوصيلات وليس من النتيجة). وفي كلتا الحالتين، تتوقف مجموعة الاختبارات عن إخبارك بالحقيقة.
- المقروئية. يوثق الاختبار كيفية بناء الكود، وليس ما يضمنه. لا يمكن للقارئ معرفة أي السلوكيات تهم بالفعل، ولا يعود الاختبار صالحاً ليكون مثالاً للاستخدام أو مواصفة للكود (spec).
- الاقتران (Coupling). يثبت الاختبار قرارات التصميم الحالية، مما يثبط عمليات إعادة الهيكلة التي كان من المفترض أن تجعلها الاختبارات آمنة.
##Treatment
قم بالاختبار من خلال العقد العام (public contract) — وهو نفس السطح الذي يلمسه المتصل الفعلي أو المستخدم — وتحقق من المخرجات الملاحظة: قيم الإرجاع، أو الأخطاء التي تم إلقاؤها، أو الأحداث المنبعثة، أو الحالة المحفوظة، أو واجهة المستخدم المصيّرة/المرئية.
- حدد المستهلك (consumer). بالنسبة للوحدة البرمجية (module)، فإن المستهلك هو واجهة برمجة التطبيقات المصدرة (exported API) الخاصة بها؛ وبالنسبة لمكون واجهة المستخدم، فإنه المستخدم (النقرات، الكتابة) وما يمكنه رؤيته.
- قم بتقديم المدخلات بنفس الطريقة التي يقوم بها المستهلك، وليس عن طريق استدعاء دوال خاصة. قم بتوليد نقرة حقيقية بدلاً من استدعاء معالج الحدث (handler)؛ واستدعِ الدالة العامة بدلاً من الدالة المساعدة الداخلية.
- تحقق من النتائج وليس من التفاصيل الداخلية. استبدل فحوصات
state()أو الحقول الخاصة أوtoHaveBeenCalledبالتحقق مما ينتج عن العملية. - استعلم عن عناصر واجهة المستخدم من خلال إمكانية الوصول (accessibility) وليس البنية — مثل الدور (role)، والتسمية (label)، والنص — بدلاً من فئات CSS، أو الوسوم، أو
nth-child. - توقف عن اختبار الدوال الخاصة مباشرة. قم بتغطيتها من خلال اختبار الدالة العامة التي تستخدمها؛ وإذا كانت الوحدة الخاصة معقدة لدرجة تتطلب اختبارات منفردة، فهذا مؤشر على الحاجة إلى استخلاصها في وحدة برمجية منفصلة تمتلك واجهة برمجة تطبيقات عامة خاصة بها.
- احصر استخدام التحقق من المحاكاة/التجسس (mock/spy) على الحدود الحقيقية للبرنامج (الشبكة، الوقت، بوابة الدفع) حيث يكون الاستدعاء نفسه هو السلوك الملاحظ — وليس للمساعدين الداخليين.
// قبل: يختبر تفاصيل التنفيذ
const wrapper = mount(<Counter />);
wrapper.instance().handleClick();
expect(wrapper.state('count')).toBe(1);
expect(wrapper.find('.count-display').text()).toBe('1');
// بعد: يختبر السلوك الملاحظ عبر العقد العام الموجه للمستخدم
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('1')).toBeInTheDocument();
قاعدة عامة: إذا أدت عملية إعادة هيكلة تحافظ على السلوك إلى تعطل الاختبار، فهذا يعني أن الاختبار كان يتحقق من تفاصيل التنفيذ.
##Detected by
- eslint-testing-library testing-library/no-node-access — no-node-access
- eslint-testing-library testing-library/no-container — no-container