ConstructiCat Logo
CodeBust.
Browse section ▾

الاعتماد على ترتيب الاختبارات.

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

##Signs and Symptoms

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

العلامات الدالة:

  • ينجح الاختبار في المجموعة الكاملة ولكنه يفشل عند تشغيله بمفرده (عبر استخدام .only، أو فلتر الاسم، أو تشغيل هذا الملف فقط). يسمي "ميسزاروس" هذا بـ الاختبار الوحيد (Lonely Test).
  • تتعطل الاختبارات عند خلط الترتيب، أو تجزئتها، أو تشغيلها بالتوازي، أو بعد ترقية مشغل الاختبارات الذي يقوم بتغيير الترتيب الافتراضي.
  • حذف أو تخطي أو إعادة ترتيب اختبار واحد يؤدي إلى فشل اختبار آخر لا يبدو أن له صلة به.
  • يؤدي فشل واحد إلى حدوث سلسلة متعاقبة (cascade) من حالات الفشل اللاحقة (ما يسميه ميسزاروس بـ الاختبارات المتفاعلة - Interacting Tests؛ أو van Deursen بـ حرب تشغيل الاختبارات - Test Run War عندما تتصادم المشغلات المتوازية على هيكل اختبار مشترك).
  • تكون الاختبارات غير مستقرة بشكل متقطع (flaky) دون أي تغيير في الكود — فالاعتماد على الترتيب هو أحد أكثر الأسباب الجذرية شيوعاً للاختبارات غير المستقرة.

المؤشر الهيكلي الواضح هو وجود حالة مشتركة قابلة للتعديل (shared mutable state) تتم قراءتها عبر الاختبارات: متغيرات على مستوى الوحدة البرمجية/ثابتة static/عامة، أو دالة beforeAll تقوم بملء الحالة مرة واحدة، أو مورد خارجي لم يتم إعادة تعيينه (صفوف قاعدة البيانات، الملفات، الذاكرة المؤقتة، متغيرات البيئة، المؤقتات المزيفة، كائنات المحاكاة).

let users = []; // حالة مشتركة على مستوى الوحدة البرمجية

test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});

// ينجح فقط لأن الاختبار أعلاه تم تشغيله أولاً وقام بتعديل `users`.
// قم بتشغيل هذا الاختبار بمفرده، أو اخلط الترتيب، وسيفشل الاختبار.
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

##Reasons for the Problem

لماذا يحدث ذلك

  • يتم تعديل هيكل اختبار مشترك (Shared Fixture) (يتم إعداده مرة واحدة في beforeAll، أو كمتغير على مستوى الوحدة البرمجية، أو كعضو ثابت static في فئة، أو كقاعدة بيانات/ملف حقيقي) بواسطة الاختبارات ولا يتم إعادة تعيينه أبداً بينها، وبالتالي يرث كل اختبار مخلفات الاختبار السابق.
  • الراحة والسرعة: يعيد المطورون استخدام عمليات إعداد مكلفة أو "يبنون على" بيانات الاختبار السابق لتجنب إعادة إنشائها (وهو ما يُعرف أحياناً بـ الاختبارات المتسلسلة - Chained Tests).
  • يكون الاعتماد عرضياً وغير مرئي — فلا يوجد شيء في كود الاختبار يعلن "قم بتشغيلي بعد ذلك الاختبار"، وبالتالي يستمر هذا الوضع حتى يتغير الترتيب.

لماذا يضر ذلك

  • الموثوقية / الثقة الزائفة. تنجح مجموعة الاختبارات بالكامل بمجرد الحظ في الترتيب. قم بإعادة الترتيب، أو التشغيل المتوازي، أو تشغيل جزء منها وستفشل الاختبارات (أو الأسوأ من ذلك، يختفي خطأ برمي حقيقي لأن اختباراً سابقاً قد ترك حالة "صحيحة" بالصدفة). تعتبر الاختبارات المعتمدة على الترتيب مصدراً رئيسياً لـ الاختبارات غير المستقرة (flaky tests).
  • قابلية الصيانة. لا يمكنك تشغيل اختبار فاشل واحد أو تصحيحه أو إعادة تشغيله بشكل مستقل — فيجب تشغيل الاختبار التمهيدي أولاً. ويؤدي إضافة اختبارات أو إزالتها أو إعادة ترتيبها إلى تأثيرات غامضة غير مباشرة (spooky action at a distance) في اختبارات أخرى.
  • المقروئية. لا يعود الاختبار يوثق سلوكاً واحداً بمدخلات صريحة؛ بل يتطلب فهمه قراءة كل ما تم تشغيله قبله (ضيف غامض Mystery Guest مخبأ في ترتيب التنفيذ).
  • يعيق التشغيل المتوازي واختيار الاختبارات. تفترض أدوات تجزئة الاختبارات (test sharding)، والمشغلات المتوازية، وأدوات تحديد الاختبارات المتأثرة بالمرونة استقلالية الاختبارات؛ ويجعل اقتران الترتيب استخدامها غير آمن.

##Treatment

اجعل كل اختبار مستقلاً بذاته: يقوم بإعداد كل ما يحتاجه، والتحقق، والتنظيف، بحيث ينتج نفس النتيجة في أي ترتيب، سواء بمفرده أو ضمن مجموعة اختبارات.

  1. امنح كل اختبار هيكلاً جديداً (Fresh Fixture). انقل الإعداد المشترك من beforeAll إلى beforeEach (أو ابنِ الهيكل داخل الاختبار نفسه)، بحيث يتم إعادة بناء الحالة لكل اختبار بدلاً من تراكمها.
  2. تخلص من الحالة المشتركة القابلة للتغيير. لا تقم بالقراءة/الكتابة من أو إلى متغيرات على مستوى الوحدة البرمجية، أو متغيرات static، أو متغيرات عامة عبر الاختبارات. ابنِ الكائنات محلياً، ومرر البيانات بشكل صريح.
  3. أعد تعيين الموارد الخارجية في خطوة التفكيك (teardown). قم بالتراجع عن تغييرات قاعدة البيانات (معاملة واحدة لكل اختبار) أو استخدم بيانات فريدة لكل اختبار؛ واحذف الملفات المؤقتة؛ واسترجع قيم متغيرات البيئة والمتغيرات العامة والمؤقتات المزيفة؛ وامسح كائنات المحاكاة (مثل jest.clearAllMocks() / vi.restoreAllMocks()، وjest.resetModules()). فضل التفكيك التلقائي/المضمون على التنظيف اليدوي.
  4. أثبت الاستقلالية عن طريق إضفاء العشوائية على الترتيب. هذا هو الكاشف الحقيقي — فهو خاصية ديناميكية لا يمكن لأداة التحليل الإملائي (linter) رؤيتها:
    • Jest: --randomize / randomize: true.
    • Vitest: sequence.shuffle (في الإعدادات أو عبر --sequence.shuffle).
    • pytest: pytest-randomly أو pytest-random-order.
    • Maven Surefire: -Dsurefire.runOrder=random.
      وقم بتشغيل جزء من الاختبارات/اختبار واحد في بيئة CI أيضاً لتظهر الاختبارات الوحيدة (Lonely Tests).
  5. إذا كان الإعداد المشترك مطلوباً حقاً (هيكل اختبار مكلف للقراءة فقط)، فاجعله غير قابل للتعديل (immutable) ومشاركاً للقراءة فقط، أو استخدم مجموعة اختبارات متسلسلة (Chained Tests) موثقة عمداً كملاذ أخير — وتجنب تماماً الاعتماد العرضي. يرجى ملاحظة أن قاعدة no-hooks في eslint-plugin-jest/eslint-plugin-vitest قد تثبط استخدام خطافات الإعداد/التفكيك التي تميل إلى تعزيز استخدام الحالة المشتركة، ولكنها لا تكتشف الاعتماد على الترتيب بحد ذاتها.
// قبل — معتمد على الترتيب: يحتاج الاختبار الثاني إلى مخلفات الاختبار الأول
let users = [];
test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

// بعد — يمتلك كل اختبار هيكل الاختبار الخاص به
function makeRepo(seed = []) {
  return { users: [...seed] };
}

test('creates a user', () => {
  const repo = makeRepo();
  repo.users.push({ id: 1, name: 'Ada' });
  expect(repo.users).toHaveLength(1);
});

test('finds an existing user', () => {
  const repo = makeRepo([{ id: 1, name: 'Ada' }]); // يعد الشرط المسبق الخاص به
  expect(repo.users.find(u => u.name === 'Ada')).toBeDefined();
});