الاختبار النائم.
يقوم الاختبار النائم بإيقاف التنفيذ مؤقتاً باستخدام تأخير ثابت مشفر (`Thread.sleep`، أو `setTimeout`، أو `cy.wait(2000)`، أو `page.waitForTimeout`) لانتظار انتهاء العمل غير المتزامن بدلاً من انتظار الشرط الفعلي نفسه.
##Signs and Symptoms
ترى تأخيراً برقم سحري يقع بين الإجراء وعملية التحقق منه، مع تعليق يعتذر عن ذلك:
test('order is processed', async () => {
submitOrder(order);
// إعطاء العامل (worker) بعض الوقت للانتهاء
await new Promise((r) => setTimeout(r, 2000));
expect(await getOrderStatus(order.id)).toBe('processed');
});
العلامات الدالة:
- وجود فترات انتظار ثابتة (sleeps) في أجسام الاختبارات أو الخطافات (hooks):
Thread.sleep(500)، أوawait sleep(1000)، أوtime.sleep(2)، أوcy.wait(3000)، أوawait page.waitForTimeout(2000)، أوbrowser.pause(2000). - تعليقات مثل
// wait for the animation، أو// let the DB catch up، أو// flaky without this. - تزداد مدة الانتظار بمرور الوقت (يتحول
1000إلى2000ثم إلى5000) حيث يكافح المطورون حالات الفشل المتقطع عن طريق زيادة التأخير. - الرقم هو وسيلة التزامن الوحيدة — فلا توجد عملية تحقق أو استطلاع أو حدث يرتبط به الانتظار بشكل فعلي.
- مجموعة اختبارات بطيئة حيث يهيمن الانتظار الخامل على وقت التشغيل الإجمالي بدلاً من العمل الفعلي.
تتعلق هذه المشكلة (smell) بالانتظار غير المشروط. أما الانتظارات التي تعتمد على شرط، مثل cy.wait('@apiAlias') أو await expect(locator).toBeVisible()، فهي صحيحة ومقبولة؛ بينما الانتظار المعتمد على الوقت فقط مثل cy.wait(2000) وpage.waitForTimeout(2000) فغير مقبول.
##Reasons for the Problem
لماذا يحدث ذلك
- العمليات غير المتزامنة (مثل قائمة الانتظار، أو المؤقت، أو الرسوم المتحركة، أو استدعاء الشبكة، أو خيط التنفيذ الخلفي) لا تملك خطافاً (hook) واضحاً لانتظاره، لذا فإن إضافة تأخير زمنية هي الطريق الأسهل والأقل مقاومة.
- كان الاختبار يفشل بشكل متقطع وقام شخص ما بتعديله أو "إصلاحه" عن طريق إدخال أو إطالة وقت الانتظار (sleep) حتى نجح الاختبار على جهازه الخاص.
- يتفاعل الاختبار مع نظام خارجي حقيقي (مثل الساعة، أو المجدول، أو نظام الملفات، أو خدمة HTTP) والذي لا يمكن تتبع اكتمال عملياته بسهولة، مما يجعل المطور يخمن التوقيت.
لماذا يضر ذلك
- الموثوقية / الثقة الزائفة. يقوم الانتظار الثابت بترميز افتراض معين — "العمل ينتهي خلال N مللي ثانية". يختلف وقت المعالجة باختلاف الأجهزة، وحمل خوادم التكامل المستمر (CI)، والتشغيلات المختلفة، وبالتالي يصبح الاختبار غير حتمي: ينجح محلياً ويفشل على بيئة CI المحملة، أو الأسوأ من ذلك، أن يكون وقت الانتظار قصيرًا جدًا وتتسابق عملية التحقق مع الكود، وتنجح فقط بسبب الحظ في التوقيت. هذا أحد أكثر الأسباب الجذرية المذكورة للاختبارات غير المستقرة (flaky tests).
- البطء. ينتظر وقت النوم الثابت دائماً المدة الكاملة، حتى لو انتهى العمل في غضون 20 مللي ثانية. وبضرب هذه الأوقات عبر مجموعة الاختبارات، يحول الانتظار الثابت ثوانٍ من العمل الفعلي إلى دقائق من الانتظار الخامل، مما يثبط المطورين عن تشغيل الاختبارات بشكل متكرر.
- قابلية الصيانة. الرقم السحري المستخدم هنا هش: إن تسريع أو إبطاء النظام الخاضع للاختبار يؤدي بصمت إلى كسر عقد التوقيت، و"الإصلاح" الوحيد الذي يلجأ إليه الناس هو زيادة الرقم — وهي حلقة مفرغة تجعل مجموعة الاختبارات أبطأ ومع ذلك تظل غير مستقرة (flaky).
- المقروئية. يخفي التأخير الشيء الذي ينتظره الاختبار بالفعل. فلا يمكن للقارئ معرفة ما إذا كان
sleep(2000)يحمي كتابة في قاعدة بيانات، أم عملية تصيير، أم لا شيء على الإطلاق، مما يعتم على نية الاختبار وهدفه.
##Treatment
استبدل "الانتظار لفترة زمنية محددة" بـ "الانتظار حتى تحقق الشرط". حدد الإشارة القابلة للملاحظة التي تدل على انتهاء العمل غير المتزامن، ثم انتظر هذه الإشارة مع تحديد مهلة زمنية (timeout) كافية.
- الاستطلاع لمعرفة ما إذا كان الشرط قد تحقق (Polling). استخدم أداة مساعدة للاستطلاع أو إعادة المحاولة — مثل Awaitility (في الـ JVM)، أو
vi.waitFor/waitFor(في Vitest أو Testing Library)، أوwaitForالخاصة بـ Jest، أو التحققات التي تعيد المحاولة تلقائياً في مشغل الاختبارات الخاص بك — بحيث يستمر الاختبار فور تحقق الشرط، ويفشل فقط بعد نفاد المهلة الزمنية. - فضل استخدام تحققات الانتظار المدمجة (awaited assertions). تقوم أدوات اختبار واجهة المستخدم (Web E2E) بإعادة المحاولة تلقائياً بالفعل: تحققات Playwright الأولى للويب (مثل
await expect(locator).toBeVisible()) وأوامر Cypress التي تعيد المحاولة تلقائياً تلغي الحاجة تماماً للانتظار اليدوي. - انتظر الأحداث وليس الساعة. انتظر الوعد (Promise)، أو انتظر استجابة الشبكة (network response)، أو استخدم
CountDownLatchأو استدعاءً مرجعياً (callback) أوwaitFor('@alias')والذي يرسل كود الإنتاج إشارته بالفعل. - تحكم في الوقت بدلاً من إهداره. عندما يكون التأخير عبارة عن مؤقت حقيقي في الكود الخاضع للاختبار، استخدم المؤقتات المزيفة (مثل
jest.useFakeTimers()، أوvi.useFakeTimers()، أوvi.advanceTimersByTimeAsync) بحيث تقدم الساعة بشكل حتمي بدلاً من إيقاف الاختبار مؤقتاً.
قبل:
test('order is processed', async () => {
submitOrder(order);
await new Promise((r) => setTimeout(r, 2000)); // نائم (sleepy)
expect(await getOrderStatus(order.id)).toBe('processed');
});
بعد (استطلاع الشرط مع مهلة زمنية محدودة):
import { vi } from 'vitest';
test('order is processed', async () => {
submitOrder(order);
await vi.waitFor(
async () => expect(await getOrderStatus(order.id)).toBe('processed'),
{ timeout: 5000, interval: 50 },
);
});
المكافئات في Playwright/Cypress:
// Playwright — التحقق المخصص للويب يعيد المحاولة تلقائياً حتى يظهر العنصر أو تنفد المهلة
await expect(page.getByText('processed')).toBeVisible();
// Cypress — الانتظار حتى اكتمال الطلب، وليس الانتظار لرقم محدد
cy.intercept('POST', '/orders').as('createOrder');
cy.wait('@createOrder');
النتيجة هي عدم الانتظار أكثر من اللازم، والفشل السريع برسالة واضحة عند انتهاء المهلة، والتخلص من الاعتماد على سرعة جهاز المطور.
##Detected by
- sonar java:S2925 — لا ينبغي استخدام "Thread.sleep" في الاختبارات
- eslint-plugin-playwright playwright/no-wait-for-timeout — no-wait-for-timeout (منع page.waitForTimeout)
- eslint-plugin-cypress cypress/no-unnecessary-waiting — no-unnecessary-waiting (منع cy.wait مع رقم)
- eslint-plugin-ui-testing ui-testing/no-hard-wait — no-hard-wait (منع الانتظار الثابت: cy.wait, page.waitForTimeout, browser.pause, t.wait)