المحاكاة المفرطة.
يربط الاختبار عدداً كبيراً جداً من الكائنات الوهمية (mock objects) والتفاعلات البديلة (stubbed interactions) لدرجة أن تجهيز المحاكاة يقزم التحقق الفعلي، مما يجعل الاختبار ينتهي باختبار المحاكاة بدلاً من السلوك الحقيقي.
##Signs and Symptoms
يتكون الاختبار في معظمه من خطوة الترتيب (arrange): كتل طويلة من إنشاء المحاكاة، وأسطر when(...).thenReturn(...) / mockReturnValue(...) و verify(...)، مع القليل جداً من الكود الحقيقي قيد الاختبار. العلامات الدالة:
- سباكة المحاكاة أكثر من التحققات الفعلية. الإعداد يتكون من أسطر كثيرة؛ والتنفيذ (act) عبارة عن استدعاء واحد؛ والتحقق (assert) يفحص ما إذا كانت المحاكاة قد استُدعيت بدلاً من فحص صحة النتيجة.
- محاكاة أشياء تملكها. يتم محاكاة كائنات النطاق (domain objects)، أو كائنات القيم (value objects)، أو المتعاونين ذوي المنطق النقي بدلاً من الاقتصار على الحدود الخارجية فقط (الشبكة، قاعدة البيانات، الساعة، نظام الملفات، المكتبات الخارجية).
- سلاسل المحاكاة / "حطام القطار". محاكاة تعيد محاكاة أخرى تعيد محاكاة ثالثة، مما يعكس مخطط الاستدعاء لكود الإنتاج.
- التحقق من التفاعل فقط. يتحقق الاختبار من استدعاء
toHaveBeenCalledWith(...)على كل متعاون ولا يتحقق أبداً من القيمة المرجعة أو الحالة الناتجة. - الهشاشة عند إعادة الهيكلة (refactor). يؤدي إعادة ترتيب الاستدعاءات الداخلية أو استخراج طريقة ما إلى كسر العديد من الاختبارات على الرغم من عدم تغير السلوك.
- خمس محاكاة أو أكثر لمجرد إنشاء النظام تحت الاختبار (SUT) — عادة ما تكون علامة على أن النظام نفسه لديه تبعيات كثيرة جداً.
test('places order', () => {
const inventory = { check: jest.fn().mockReturnValue(true) };
const pricing = { quote: jest.fn().mockReturnValue(42) };
const tax = { calc: jest.fn().mockReturnValue(4.2) };
const wallet = { charge: jest.fn().mockReturnValue({ ok: true }) };
const ledger = { record: jest.fn() };
const emailer = { send: jest.fn() };
const audit = { log: jest.fn() };
const clock = { now: jest.fn().mockReturnValue(0) };
const svc = new OrderService(inventory, pricing, tax, wallet, ledger, emailer, audit, clock);
svc.place(cart);
// يتحقق من استدعاء المحاكاة — وليس من صحة الطلب
expect(inventory.check).toHaveBeenCalled();
expect(pricing.quote).toHaveBeenCalled();
expect(wallet.charge).toHaveBeenCalledWith(46.2);
expect(ledger.record).toHaveBeenCalled();
});
##Reasons for the Problem
لماذا يحدث ذلك
- النظام تحت الاختبار (SUT) لديه الكثير من المتعاونين (collaborators). المحاكاة المفرطة عادة ما تكون إشارة تصميمية: فئة ذات تماسك منخفض وتبعيات كثيرة تجبر كل اختبار على إعدادها جميعاً. كما هو موضح في أدبيات الكتالوج، "إذا كنت بحاجة إلى محاكاة خمس فئات داخلية لمجرد اختبار طريقة واحدة، فإن هذه الطريقة بها الكثير من التبعيات — وهذه مشكلة تصميمية وليست مشكلة اختبار."
- العادة / رد فعل "محاكاة كل شيء". أخذ الاختبار القائم على التفاعل (مدرسة لندن) إلى أقصى حد، أو المحاكاة التلقائية لتجنب لمس قاعدة البيانات أو الشبكة، يؤدي إلى محاكاة المنطق النقي الذي يمكن اختباره مباشرة.
- صعوبة بناء الكائنات الحقيقية. عندما يكون بناء المتعاونين الحقيقيين أمراً صعباً، تبدو المحاكاة أسهل من إصلاح الباني أو إضافة كائن وهمي (fake).
لماذا يضر ذلك
- الثقة الزائفة. تشفر الكائنات الوهمية (Mocks) افتراضاتك حول التبعية. فإذا اختلف التنفيذ الحقيقي، يظل الاختبار ناجحاً — على سبيل المثال، بديل (stub) يدعي أن دالة
sum()ترجع أعداداً صحيحة موجبة فقط يبقي الاختبار باللون الأخضر حتى بعد تغيير الطريقة الحقيقية. يمكن أن تصبح مثل هذه الاختبارات تحصيل حاصل: فهي تتحقق فقط من أن الماكيتات التي كتبتها تتصرف مثل الماكيتات التي كتبتها. وكما تحذر توثيقات Mockito نفسها: "إذا تمت محاكاة كل شيء، فهل نختبر كود الإنتاج حقاً؟" - الهشاشة / تكلفة صيانة عالية. التحقق من استدعاءات معينة يعامل تفاصيل التنفيذ كعقد، لذا فإن عمليات إعادة الهيكلة (refactoring) غير الضارة (ترتيب الاستدعاءات، استخراج الدوال المساعدة) تؤدي إلى كسر الاختبارات. هذا هو ما يسميه ميسزاروس البرمجيات المحددة بشكل مفرط / الاختبار الهش.
- ضعف سهولة القراءة. تدفن مئات الأسطر من إعداد التبعيات الشيء الوحيد الذي يدور حوله الاختبار، مثل رائحة الضيف الغامض (Mystery Guest) — فلا يمكن للمراجع معرفة السلوك الذي يتم التحقق منه بالفعل.
- أخطاء التكامل المفقودة. لا يتم تشغيل الترابط الحقيقي بين المكونات أبداً؛ وتعيش الأخطاء على وجه التحديد في الشقوق التي استبدلتها المحاكاة. التغطية العالية تحجب الجودة المنخفضة.
##Treatment
تعامل مع المحاكاة المكثفة كإشارة تعليق (feedback)، ثم قلل من الحاجة للمحاكاة:
- أصلح التصميم أولاً. إذا كان يجب عليك محاكاة 5 متعاونين أو أكثر لبناء النظام تحت الاختبار، فقم بتقسيم المسؤوليات أو تقليل التبعيات في الباني. التبعيات الحقيقية الأقل تعني محاكاة أقل.
- حاكِ فقط عند حدود البنية البرمجية (architecture boundaries). حاكِ الأشياء التي لا تملكها (الشبكة، قاعدة البيانات، الساعة، نظام الملفات، واجهات برمجة التطبيقات الخارجية)؛ واستخدم نسخاً حقيقية لكائنات النطاق والقيم الخاصة بك.
- استخرج نواة نقية (functional core / imperative shell). انقل منطق الحساب/القرار خارج الفئة كثيفة المدخلات والمخرجات (I/O-heavy) بحيث يمكن اختبارها بـ صفر محاكاة، مع ترك غلاف رقيق يحتاج فقط إلى محاكاة واحدة أو اثنتين للحدود.
- فضل التحقق من الحالة على التحقق من التفاعل. تحقق من القيمة المرجعة أو الحالة الناتجة بدلاً من
verify(...)/toHaveBeenCalledWith(...)على كل متعاون. احتفظ بفحوصات التفاعل للأثر الجانبي الوحيد الذي يهمك بالفعل. - استخدم أبسط كائن بديل (double) يفي بالغرض. استبدل المحاكاة المعقدة بـ stubs (تكتفي بإرجاع القيم) أو ببديل واحد قابل لإعادة الاستخدام في الذاكرة (in-memory fake) بدلاً من إعادة محاكاة كل طريقة لكل اختبار.
- اكتشف المحاكاة المفرطة ديناميكياً. تفعيل المحاكاة الصارمة لـ Mockito (الافتراضية في
MockitoExtension/MockitoJUnitRunner) يلقي استثناءUnnecessaryStubbingExceptionللبدائل التي تم إعدادها ولكن لم يتم استخدامها أبداً — وهي طريقة بسيطة للعثور على المحاكاة التي لم تكن بحاجة إليها.
// قبل: 8 محاكاة، والتحقق من الاستدعاءات
const pricing = { quote: jest.fn().mockReturnValue(42) };
const tax = { calc: jest.fn().mockReturnValue(4.2) };
// + 6 محاكاة إضافية ...
expect(wallet.charge).toHaveBeenCalledWith(46.2);
// بعد: اختبار المنطق النقي مباشرة — بدون محاكاة
expect(totalFor(cart, rates)).toBe(46.2);
// تتم محاكاة الحدود الحقيقية فقط؛ والتحقق من الحالة وليس من الاستدعاءات
const wallet = new InMemoryWallet({ balance: 100 });
const svc = new OrderService(new InMemoryInventory(cart), wallet);
const order = svc.place(cart);
expect(order.total).toBe(46.2);
expect(wallet.balance).toBe(53.8);