حرب تشغيل الاختبارات.
تحدث حرب تشغيل الاختبارات (Test Run War) عندما تنجح الاختبارات لشخص واحد ولكنها تفشل عشوائياً بمجرد قيام عدة أشخاص أو مهام تكامل مستمر (CI) بتشغيل مجموعة الاختبارات في نفس الوقت، وذلك بسبب تصادم الاختبارات على هيكل اختبار مشترك ومستمر.
##Signs and Symptoms
تتعرف على حرب تشغيل الاختبارات من خلال مَن يقوم بالتشغيل ومتى، وليس من خلال جسم الاختبار نفسه:
- تكون مجموعة الاختبارات ناجحة (خضراء) على جهازك وفي تشغيلات الـ CI الفردية، ولكنها تفشل (تصبح حمراء) "دون سبب" عندما يقوم زميل بتشغيلها في نفس الوقت، أو عندما تستهدف مهمتا CI البيئة نفسها.
- تكون الإخفاقات عابرة وغير قابلة لإعادة الإنتاج — فعادةً ما يؤدي إعادة تشغيل الاختبار نفسه إلى نجاحه.
- تتركز الإخفاقات حول مورد مشترك قابل للتغيير ومستمر: قاعدة بيانات اختبار مشتركة، أو حاوية S3/طابور/ذاكرة مؤقتة مشتركة، أو حساب/مستأجر (tenant) ثابت.
- أشكال الأخطاء تكون دالة على ذلك: مثل انتهاك قيود المفتاح المكرر/الفريد (duplicate-key / unique-constraint)، أو رسالة مثل
expected 1 row but found 2، أوrecord not found(لأن تشغيل شخص آخر قام بحذفه).
// يفترض كلا الاختبارين أنهما يمتلكان بشكل حصري قاعدة بيانات اختبار مشتركة (SHARED).
test('creates a user', async () => {
await db.users.insert({ id: 42, email: 'alice@example.com' }); // مفتاح ثابت (hardcoded)
expect(await db.users.findById(42)).toMatchObject({ email: 'alice@example.com' });
});
test('counts active users', async () => {
expect(await db.users.countActive()).toBe(1); // يفترض أنه لم يقم أحد آخر بإضافة صفوف
});
عندما يستهدف مشغلان قاعدة البيانات نفسها في نفس الوقت، يتصادم الإدخال على المعرف id: 42 (مفتاح مكرر) ولا يعود العدد الإجمالي 1. ينجح كل اختبار بمفرده؛ ولكنهما يتعاركان عند التشغيل المتزامن معاً.
##Reasons for the Problem
لماذا يحدث ذلك. تعتمد الاختبارات على هيكل اختبار مشترك (Shared Fixture) عالمي — وعادة ما يكون مورداً مستمراً واحداً (قاعدة بيانات اختبار مشتركة واحدة، أو حاوية مشتركة، أو مستخدم ثابت) بدلاً من هيكل اختبار جديد (Fresh Fixture) يتم إنشاؤه لكل اختبار. تفترض المعرفات والتحققات الثابتة (hardcoded) على الحالة العامة بصمت أن الاختبار يمتلك المورد بشكل حصري. وطالما أن تشغيلاً واحداً فقط يصل إليه في كل مرة، يظل هذا الوهم قائماً. ولكن عند إضافة التوازي — مطور ثانٍ، أو مهام CI متوازية، أو عمال اختبار متوازيين — تتداخل عمليات التشغيل على نفس الصفوف والمفاتيح.
لماذا يضر ذلك. يصنف "ميسزاروس" هذه المشكلة كشكل من أشكال الاختبارات غير المستقرة (Erratic Test): وهي في الأساس مشكلة اختبارات متفاعلة (Interacting Tests) حيث يكون التفاعل بين تشغيلين متزامنين للاختبارات بدلاً من أن يكون بين اختبارات مختلفة في تشغيل واحد.
- الموثوقية: تكون الإخفاقات غير حتمية وتعتمد على من يقوم بالتشغيل في نفس الوقت، وبالتالي لا يمكن إعادة إنتاجها عند الطلب.
- الثقة الزائفة وفقدان الثقة: يتعلم الفريق "مجرد إعادة التشغيل"، ويبدأ في تجاهل عمليات البناء الحمراء (الفاشلة) — بما في ذلك الأخطاء البرمجية الحقيقية (regressions) المختبئة وسط هذه الضوضاء.
- قابلية الصيانة / سهولة التصحيح: السبب غير مرئي في الاختبار الفاشل؛ فهو يعيش في تشغيل آخر، وتداخل العمليات يجعل العثور على السبب الجذري أمراً غاية في الصعوبة.
- قابلية التوسع: يعيق هذا الأمر أمرين ترغب فيهما الفرق بشدة — وهما تشغيل الاختبارات بالتوازي وزيادة عدد الأشخاص الذين يمكنهم تشغيلها في نفس الوقت.
##Treatment
تخلص من الحالة المشتركة المتنازع عليها. الخطوات المحددة، مرتبة تقريباً حسب التفضيل:
- فضل استخدام هيكل اختبار جديد (Fresh Fixture). يقوم كل اختبار بإنشاء البيانات التي يحتاجها بدقة ثم يفككها — أو يعمل داخل معاملة (transaction) يتم التراجع عنها عند التفكيك. لا تعتمد على صفوف مشتركة موجودة مسبقاً.
- اعزل بيئة التشغيل لكل مشغل (صندوق رمل لقاعدة البيانات / تجزئة). امنح كل مطور وكل مهمة/عامل CI قاعدة بيانات أو مخططاً (schema) أو مساحة اسم خاصة به (مخطط لكل عامل schema-per-worker، أو قاعدة بيانات حاوية مؤقتة ephemeral container DB، أو قاعدة بيانات فرعية). عندئذٍ، لا يمكن للتشغيلات المتزامنة رؤية بعضها البعض ببساطة.
- استخدم قيم توليد مميزة للمفاتيح. استبدل المعرفات/رسائل البريد الإلكتروني الثابتة بقيم توليد فريدة (UUID، أو تسلسل، أو
timestamp + workerId) بحيث تقوم التشغيلات المتزامنة بإنشاء كائنات متميزة بدلاً من التصادم. - لا تتحقق من الأعداد الإجمالية العالمية (global counts). اجعل نطاق التحقق مقتصراً على البيانات التي أنشأها هذا الاختبار (تصفية حسب المفتاح الفريد/المستأجر الخاص به) بدلاً من التحقق من أن
count == 1. - اجعل الموارد الخارجية لكل اختبار/لكل عامل. مجلدات مؤقتة فريدة، أسماء طوابير/مواضيع فريدة، قاعدة بيانات في الذاكرة (in-memory) أو Testcontainer لكل عملية.
// قبل: مفتاح ثابت + تحقق عام من قاعدة بيانات مشتركة
await db.users.insert({ id: 42, email: 'alice@example.com' });
expect(await db.users.countActive()).toBe(1);
// بعد: مفتاح فريد + تحقق محدد النطاق (هيكل اختبار جديد + قيم توليد مميزة)
const id = crypto.randomUUID();
const email = `alice+${id}@example.com`;
await db.users.insert({ id, email });
expect(await db.users.findById(id)).toMatchObject({ email });
// ...ووجّه كل عامل اختبار إلى المخطط/قاعدة بيانات صندوق الرمل الخاصة به
ملاحظة: لا توجد أداة تحليل إملائي (linter) لهذا الخطأ — فهو يظهر فقط أثناء وقت التشغيل تحت ظروف التوازي. اجعله قابلاً للتكرار من خلال تشغيل مجموعة الاختبارات عمداً مرتين بالتوازي ضد نفس البيئة (أو زيادة عدد عمال تشغيل الاختبارات) في بيئة الـ CI؛ فإذا تسبب ذلك في فشل المجموعة، فهذا يعني أن لديك "حرب تشغيل اختبارات" بحاجة للإصلاح.