الاختبار غير المستقر (المتقلب).
ينجح الاختبار غير المستقر (المتقلب) ويفشل بشكل متقطع على نفس الكود لأن نتيجته تعتمد على التوقيت أو التترتيب أو الحالة المشتركة أو غيرها من العوامل غير الحتمية بدلاً من السلوك قيد الاختبار.
##Signs and Symptoms
الاختبار الذي يكون ناجحاً (أخضر) في تشغيل وفاشلاً (أحمر) في التشغيل التالي دون أي تغيير في الكود هو اختبار متقلب. يمكنك التعرف عليه من خلال سلوك المطورين حوله بقدر ما تتعرف عليه من الكود: يعيد المطورون تشغيل CI "لينجح"، أو يضيفون علامة @Flaky/retry(3)، أو يضعون الاختبار في الحجر الصحي بدلاً من إصلاحه.
المؤشرات الشائعة في الكود ونمط الفشل:
- التوقيت/العمليات غير المتزامنة: استخدام
sleep/setTimeoutلـ "الانتظار"، أو الوعود (promises) غير المنتظرة بـ await، أو التحققات التي تتسابق مع النظام تحت الاختبار. ترتبط الإخفاقات بسرعة الجهاز أو بضغط العمل في بيئة التكامل المستمر (CI). - الاعتماد على الترتيب: ينجح الاختبار عند تشغيله بمفرده ولكنه يفشل ضمن مجموعة الاختبارات الكاملة، أو العكس. يؤدي تغيير ترتيب الاختبارات إلى تغيير النتيجة.
- الحالة المشتركة القابلة للتغيير: المجموعات الساكنة (static collections)، أو صف قاعدة بيانات معاد استخدامه، أو كائن فردي (singleton)، أو تجهيز تم تعديله بواسطة اختبار سابق.
- المدخلات غير الحتمية: استدعاءات
Date.now()/new Date()، أوMath.random()، أو المنطقة الزمنية/المحلية، أو ترتيب تكرار الخريطة (hash-map)، أو المعرفات التلقائية. - الموارد الخارجية: الشبكة الحقيقية، أو نظام الملفات، أو ساعة النظام. تبدو الإخفاقات مثل انتهاء المهلة (timeouts) أو "تم رفض الاتصال"، وليس عدم تطابق التحقق.
- التحققات الشرطية: إخفاء
expectداخلif/catchأو دوال الاستدعاء (callbacks)، مما يجعل الاختبار ينجح بصمت عندما لا يتم تشغيل الفرع مطلقاً.
// رائحة: انتظار مكتوب بشكل ثابت، ومصفوفة مشتركة، وساعة حقيقية
const created = []; // مشترك عبر الاختبارات ← اختبارات متفاعلة
test('shows a fresh receipt', async () => {
render(<Checkout />);
fireEvent.click(screen.getByText('Pay'));
await new Promise(r => setTimeout(r, 300)); // نأمل أن يكون الطلب قد انتهى
created.push('order-1'); // يتسرب إلى الاختبارات اللاحقة
expect(screen.getByRole('status'))
.toHaveTextContent(`Paid ${new Date().toISOString()}`); // يتغير مع كل تشغيل
});
هذا يطابق مباشرة الروائح الفرعية لـ الاختبار غير المستقر لميسزاروس: الاختبارات المتفاعلة / حرب تشغيل الاختبارات (الحالة المشتركة)، الاختبار الوحيد (يعمل فقط بعد اختبار آخر)، التفاؤل بالموارد (يفترض وجود مورد خارجي)، تسرب الموارد (لا يقوم بالتنظيف)، و الاختبار غير الحتمي / غير القابل للتكرار (الوقت، العشوائية، العمليات غير المتزامنة).
##Reasons for the Problem
لماذا يحدث ذلك
- التوقيت الضمني. تتم "مزامنة" الكود غير المتزامن باستخدام فترات نوم (
sleep) ثابتة أو بعدم استخدام await على الإطلاق. ويكون هذا التأخير مجرد تخمين: كافٍ اليوم، وقصير جداً تحت ضغط العمل غداً. - الاقتران الخفي. تشترك الاختبارات في قاعدة بيانات، أو كائن فردي (singleton)، أو متغيرات على مستوى الوحدة، أو ملفات على القرص. تصبح التأثيرات الجانبية لأحد الاختبارات شروطاً مسبقة لاختبار آخر (وهو ما يسميه ميسزاروس الاختبارات المتفاعلة / حرب تشغيل الاختبارات)، لذا تعتمد النتائج على الترتيب وعلى ما يتم تشغيله بالتوازي.
- تسرب مدخلات غير حتمية. الوقت الحقيقي للساعة، ودالة
Math.random()، والمنطقة الزمنية/المحلية، والمجموعات غير المرتبة تختلف بين مرات التشغيل والأجهزة. - الاعتماد المتفائل على البيئة. تتصل الاختبارات بشبكة/خدمة حية أو تفترض وجود ملف (التفاؤل بالموارد) ولا تقوم أبداً بتحرير ما تستحوذ عليه (تسرب الموارد).
- تحققات يمكن تخطيها. وضع
expectداخل شرط أو كتلة.catchيعني أن الفحص قد لا يتم تنفيذه أبداً، وبالتالي "ينجح" الاختبار بالصدفة.
لماذا يضر ذلك
- الثقة الزائفة والعيوب المقنعة. يمكن للاختبار المتقلب أن يفشل لأسباب لا علاقة لها بكود الإنتاج، وكذلك يمكن أن يختبئ تراجع حقيقي وراء فشل يفترض الجميع أنه "مجرد تقلب". لم يعد بإمكانك تمييز الإشارة الحقيقية عن الضوضاء.
- تآكل الثقة. بمجرد أن تُعرف مجموعة الاختبارات بأنها متقلبة، يتجاهل المطورون عمليات البناء الفاشلة (الحمراء) ويعيدون التشغيل تلقائياً، مما يدرب الفريق على تجاهل مجموعة الاختبارات بالكامل.
- الوقت الضائع ومسارات العمل المعطلة. عمليات إعادة المحاولة وإعادة التشغيل والتحقيقات لمعرفة "هل المشكلة مني أم من الاختبار؟" تبطئ الجميع وتعطل التكامل والنشر المستمر (CI/CD) بسبب قضايا غير حقيقية.
- صعوبة الصيانة. يصعب تصحيح أخطاء الاختبارات المتقلبة لأن الفشل لا يمكن إعادة إنتاجه؛ ويميل المطورون إلى تعطيلها بدلاً من إصلاحها، مما يقلل من التغطية الحقيقية بصمت.
##Treatment
تعامل مع التقلب كعيب في الاختبار، وليس كأمر غريب يتم تجاوزه بإعادة المحاولة. عمليات إعادة التشغيل هي من أجل اكتشاف التقلب، وليس لإخفائه أبداً. ضع الاختبار في الحجر الصحي إذا كان يعطل مسار العمل، ثم ابحث عن السبب الجذري.
1. استبدل فترات النوم بالانتظار المشروط. تحقق دورياً (poll) من الحالة التي تهتم بها فعلياً بدلاً من تخمين المدة.
// قبل — تأخير ثابت معرض للسباق (racy)
fireEvent.click(screen.getByText('Pay'));
await new Promise(r => setTimeout(r, 300));
expect(screen.getByRole('status')).toBeInTheDocument();
// بعد — الانتظار حتى يتحقق الشرط، ثم التحقق
fireEvent.click(screen.getByText('Pay'));
expect(await screen.findByRole('status')).toBeInTheDocument();
2. اجعل المدخلات حتمية. احقن الساعة أو استخدم مؤقتات وهمية؛ حدد قيمة أولية (seed) أو استبدل العشوائية؛ ثبت المنطقة الزمنية/المحلية.
// قبل: يعتمد على التاريخ الحقيقي
expect(label).toBe(`Paid ${new Date().toISOString()}`);
// بعد: تجميد الوقت (vitest/jest)
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
3. اعزل كل اختبار. امنح كل اختبار تجهيزات جديدة، وأعد ضبط الحالة المشتركة (قاعدة البيانات، الكائنات الفردية، المتغيرات العامة للوحدة) في beforeEach/afterEach، وتجنب المتغيرات القابلة للتعديل على مستوى الوحدة. قم بتشغيل مجموعة الاختبارات في ترتيب عشوائي (مثل --seed/randomize في Jest، أو MethodOrderer.Random في JUnit) للكشف عن الارتباط بالترتيب مبكراً.
4. استبدل الموارد الخارجية ببدائل (Stub). حاكِ (Mock) الشبكة ونظام الملفات واستدعاءات النظام حتى لا يعتمد الاختبار مطلقاً على بقاء الخدمة تعمل (يعالج التفاؤل بالموارد)؛ وقم دائماً بتحرير/تنظيف الموارد المستحوذ عليها (يعالج تسرب الموارد).
5. انتظر كل العمليات غير المتزامنة بـ await وتحقق دون شروط. أرجع/انتظر الوعود (promises) وأدوات الاختبار غير المتزامنة؛ انقل التأثيرات الجانبية خارج دوال استدعاء waitFor بحيث تعمل مرة واحدة؛ ولا تدفن expect داخل if/catch أبداً.
6. تحقق من الإصلاح. قم بتشغيل الاختبار الذي أصبح حتمياً الآن عدة مرات (وتحت ضغط العمل / بترتيب عشوائي) للتأكد من استقراره قبل إخراجه من الحجر الصحي.
##Detected by
- sonar java:S5973 — يجب أن تكون الاختبارات مستقرة
- sonar java:S2925 — يجب عدم استخدام Thread.sleep في الاختبارات
- eslint-jest no-conditional-expect — no-conditional-expect
- eslint-vitest no-conditional-expect — no-conditional-expect
- eslint-testing-library await-async-queries — await-async-queries
- eslint-testing-library await-async-utils — await-async-utils
- eslint-testing-library no-wait-for-side-effects — no-wait-for-side-effects