الاختبار غير المستقر (المتقلب).
ينجح الاختبار غير المستقر (المتقلب) ويفشل بشكل متقطع على نفس الكود لأن نتيجته تعتمد على التوقيت أو التترتيب أو الحالة المشتركة أو غيرها من العوامل غير الحتمية بدلاً من السلوك قيد الاختبار.
##العلامات والأعراض
الاختبار الذي يكون ناجحاً (أخضر) في تشغيل وفاشلاً (أحمر) في التشغيل التالي دون أي تغيير في الكود هو اختبار متقلب. يمكنك التعرف عليه من خلال سلوك المطورين حوله بقدر ما تتعرف عليه من الكود: يعيد المطورون تشغيل 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()}`); // يتغير مع كل تشغيل
});
هذا يطابق مباشرة الأسباب التي يذكرها ميسزاروس لـالاختبار غير المستقر: الاختبارات المتفاعلة (تتشارك اختبارات التشغيل الواحد الحالة؛ انظر الاعتماد على ترتيب الاختبارات)، وحرب تشغيل الاختبارات (تتنازع عدة عمليات تشغيل متزامنة على مورد مشترك مثل قاعدة بيانات؛ انظر حرب تشغيل الاختبارات)، والاختبار الوحيد (لا ينجح إلا إذا شُغّل اختبار آخر قبله، ويفشل وحده)، والتفاؤل بالموارد (يفترض وجود مورد خارجي؛ انظر التفاؤل بالموارد)، وتسرب الموارد (لا يقوم بالتنظيف)، والاختبار غير القابل للتكرار (الحالة التي يتركها تشغيل ما تغيّر نتيجة التشغيل التالي، فيختلف سلوك التشغيل الأول عن التشغيلات اللاحقة)، والاختبار غير الحتمي (الوقت والعشوائية وحالات السباق غير المتزامنة تجعل التشغيل نفسه ينجح أو يفشل عشوائياً).
##أسباب المشكلة
لماذا يحدث ذلك
- التوقيت الضمني. تتم "مزامنة" الكود غير المتزامن باستخدام فترات نوم (
sleep) ثابتة أو بعدم استخدام await على الإطلاق. ويكون هذا التأخير مجرد تخمين: كافٍ اليوم، وقصير جداً تحت ضغط العمل غداً. - الاقتران الخفي. تشترك الاختبارات في قاعدة بيانات، أو كائن فردي (singleton)، أو متغيرات على مستوى الوحدة، أو ملفات على القرص. تصبح التأثيرات الجانبية لأحد الاختبارات شروطاً مسبقة لاختبار آخر (وهو ما يسميه ميسزاروس الاختبارات المتفاعلة / حرب تشغيل الاختبارات)، لذا تعتمد النتائج على الترتيب وعلى ما يتم تشغيله بالتوازي.
- تسرب مدخلات غير حتمية. الوقت الحقيقي للساعة، ودالة
Math.random()، والمنطقة الزمنية/المحلية، والمجموعات غير المرتبة تختلف بين مرات التشغيل والأجهزة. - الاعتماد المتفائل على البيئة. تتصل الاختبارات بشبكة/خدمة حية أو تفترض وجود ملف (التفاؤل بالموارد) ولا تقوم أبداً بتحرير ما تستحوذ عليه (تسرب الموارد).
- تحققات يمكن تخطيها. وضع
expectداخل شرط أو كتلة.catchيعني أن الفحص قد لا يتم تنفيذه أبداً، وبالتالي "ينجح" الاختبار بالصدفة.
لماذا يضر ذلك
- الثقة الزائفة والعيوب المقنعة. يمكن للاختبار المتقلب أن يفشل لأسباب لا علاقة لها بكود الإنتاج، وكذلك يمكن أن يختبئ تراجع حقيقي وراء فشل يفترض الجميع أنه "مجرد تقلب". لم يعد بإمكانك تمييز الإشارة الحقيقية عن الضوضاء.
- تآكل الثقة. بمجرد أن تُعرف مجموعة الاختبارات بأنها متقلبة، يتجاهل المطورون عمليات البناء الفاشلة (الحمراء) ويعيدون التشغيل تلقائياً، مما يدرب الفريق على تجاهل مجموعة الاختبارات بالكامل.
- الوقت الضائع ومسارات العمل المعطلة. عمليات إعادة المحاولة وإعادة التشغيل والتحقيقات لمعرفة "هل المشكلة مني أم من الاختبار؟" تبطئ الجميع وتعطل التكامل والنشر المستمر (CI/CD) بسبب قضايا غير حقيقية.
- صعوبة الصيانة. يصعب تصحيح أخطاء الاختبارات المتقلبة لأن الفشل لا يمكن إعادة إنتاجه؛ ويميل المطورون إلى تعطيلها بدلاً من إصلاحها، مما يقلل من التغطية الحقيقية بصمت.
##المعالجة
تعامل مع التقلب كعيب في الاختبار، وليس كأمر غريب يتم تجاوزه بإعادة المحاولة. عمليات إعادة التشغيل هي من أجل اكتشاف التقلب، وليس لإخفائه أبداً. ضع الاختبار في الحجر الصحي إذا كان يعطل مسار العمل، ثم ابحث عن السبب الجذري.
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) أو استبدل العشوائية؛ ثبت المنطقة الزمنية/المحلية.
// قبل: يعتمد على التاريخ الحقيقي
const label = receiptLabel(); // ينسّق new Date() داخلياً
expect(label).toBe(`Paid ${new Date().toISOString()}`); // يتسابق مع الساعة
// بعد (Vitest): جمّد الساعة ثم تحقق من قيمة ثابتة
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
vi.useRealTimers();
// Jest: الأمر نفسه عبر الكائن jest
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
jest.useRealTimers();
3. اعزل كل اختبار. امنح كل اختبار تجهيزات جديدة، وأعد ضبط الحالة المشتركة (قاعدة البيانات، الكائنات الفردية، المتغيرات العامة للوحدة) في beforeEach/afterEach، وتجنب المتغيرات القابلة للتعديل على مستوى الوحدة. قم بتشغيل مجموعة الاختبارات في ترتيب عشوائي (مثل --seed/randomize في Jest، أو MethodOrderer.Random في JUnit) للكشف عن الارتباط بالترتيب مبكراً.
4. استبدل الموارد الخارجية ببدائل (Stub). حاكِ (Mock) الشبكة ونظام الملفات واستدعاءات النظام حتى لا يعتمد الاختبار مطلقاً على بقاء الخدمة تعمل (يعالج التفاؤل بالموارد)؛ وقم دائماً بتحرير/تنظيف الموارد المستحوذ عليها (يعالج تسرب الموارد).
5. انتظر كل العمليات غير المتزامنة بـ await وتحقق دون شروط. أرجع/انتظر الوعود (promises) وأدوات الاختبار غير المتزامنة؛ انقل التأثيرات الجانبية خارج دوال استدعاء waitFor بحيث تعمل مرة واحدة؛ ولا تدفن expect داخل if/catch أبداً.
6. تحقق من الإصلاح. قم بتشغيل الاختبار الذي أصبح حتمياً الآن عدة مرات (وتحت ضغط العمل / بترتيب عشوائي) للتأكد من استقراره قبل إخراجه من الحجر الصحي.
##أدوات الكشف
- sonar java:S5973 — يجب أن تكون الاختبارات مستقرة
- sonar java:S2925 — يجب عدم استخدام Thread.sleep في الاختبارات
- eslint-jest jest/no-conditional-expect — يمنع استدعاء expect بشكل شرطي
- eslint-vitest vitest/no-conditional-expect — يمنع استدعاء expect بشكل شرطي
- eslint-testing-library testing-library/await-async-queries — يفرض معالجة الوعود (promises) الصادرة عن الاستعلامات غير المتزامنة (findBy*)
- eslint-testing-library testing-library/await-async-utils — يفرض انتظار الوعود الصادرة عن الأدوات غير المتزامنة مثل waitFor
- eslint-testing-library testing-library/no-wait-for-side-effects — يمنع التأثيرات الجانبية داخل دوال رد النداء الخاصة بـ waitFor