لماذا تنجح اختبارات Playwright محليًا وتفشل في CI: ملاحظاتنا الميدانية حول الانتظار التلقائي وعزل الاختبارات والتصحيح عبر التتبّع
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗Playwright هو إطار عمل من مايكروسوفت لأتمتة المتصفح والاختبارات الشاملة، ويشغّل Chromium وFirefox وWebKit عبر واجهة واحدة. نستخدمه مع تطبيقات Next.js App Router، وفي أكثر من مشروع كانت المجموعة خضراء على أجهزة المطورين وحمراء في نحو واحدة من كل خمس عمليات تشغيل على CI. هذه ملاحظاتنا حول ماهية تلك الإخفاقات فعليًا.
أبرز النقاط
الاختبار المتذبذب في Playwright هو حالة تسابق داخل الاختبار وليس عشوائية في المتصفح. الانتظار التلقائي في Playwright يغطي قابلية العنصر للتفاعل، لكنه لا ينتظر حالة التطبيق نفسها.
فرقنا لا تستدعي page.waitForTimeout() داخل أي اختبار. التوكيدات web-first مثل expect(locator).toBeVisible() تعيد المحاولة حتى انتهاء expect.timeout (خمس ثوانٍ افتراضيًا) وتنجح فور تحقق الشرط.
العمال المتوازون يحتاجون بيانات منفصلة. يشغّل Playwright عدة عمليات عاملة افتراضيًا، لذا قد يعدّل اختباران الصف نفسه في قاعدة البيانات في اللحظة ذاتها.
بيئة CI جهاز أبطأ بخطوط ومنطقة زمنية وشبكة مختلفة. نثبّت timezoneId وlocale في playwright.config.ts ونعترض طلبات الطرف الثالث عبر page.route().
ضبط trace: 'on-first-retry' هو أهم سطر إعداد على الإطلاق. يسجّل لقطات DOM والشبكة وسجل الكونسول لعمليات التشغيل الفاشلة فقط، دون أي تكلفة على التشغيل الناجح.
ما الذي يجعل اختبار Playwright متذبذبًا فعليًا؟
الاختبار المتذبذب هو اختبار تعتمد نتيجته على التوقيت لا على سلوك التطبيق. ينفّذ Playwright فحوص قابلية التفاعل قبل كل إجراء: قبل locator.click() ينتظر أن يكون العنصر مرتبطًا ومرئيًا ومستقرًا ومفعّلًا وقادرًا على استقبال أحداث المؤشر. هذا يغطي DOM لكنه لا يغطي الحالة المهمة فعلًا: هل اكتملت عملية التعديل، هل أنهى React عملية الترطيب، وهل ظهر إشعار سيغطي الزر.
أكثر ثلاث حالات تسابق نواجهها: الضغط على زر قبل أن يربط ترطيب Next.js معالج الحدث، والتوكيد على قائمة قبل أن يعيد Server Action التحقق منها، وشريط الكوكيز الذي يعترض النقرة. ويلتقط الوضع الصارم في Playwright حالة رابعة: المحدِّد الذي يطابق أكثر من عنصر يرمي خطأً بدل اختيار الأول بصمت، وهذه ميزة لا عيب.
نمط الإصلاح ثابت: أكّد إشارة تفاعلية مثل expect(save).toBeEnabled()، ثم نفّذ الإجراء، ثم أكّد النتيجة عبر expect(page.getByRole('status')).toHaveText('Saved'). كل خطوة توكيد يعيد المحاولة بدلًا من تخمين التوقيت.
متى نستخدم التوكيدات web-first بدلًا من waitForTimeout؟
دائمًا. التوكيد web-first هو توكيد يعيد تقييم شرطه حتى ينجح أو تنتهي المهلة، مثل toBeVisible() وtoHaveText() وtoHaveCount() وtoHaveURL(). أما page.waitForTimeout(2000) فيفعل العكس: يبقى بطيئًا بقدر الرقم المخمّن، ويظل قصيرًا جدًا في اليوم الذي يكون فيه الخادم مزدحمًا.
قاعدتان نتبعهما: أكّد النتيجة لا التأخير، وفضّل المحددات القائمة على الدور مثل getByRole('button', { name: 'Save' }) على محددات CSS. المحددات القائمة على الدور يُعاد حلّها في كل محاولة وتصمد أمام إعادة هيكلة الترميز. ومحددات Playwright كسولة: page.locator() يخزّن استعلامًا لا عنصرًا، لذا يستحيل وجود مرجع عنصر قديم. وللشروط بلا مطابق جاهز، يعيد expect(async () => { ... }).toPass() محاولة كتلة كاملة بدل الانتظار الثابت.
كيف نمنع العمال المتوازين من إفساد بيانات بعضهم؟
امنح كل عامل مساحة بيانات خاصة به. يعزل Playwright حالة المتصفح مجانًا، فكل اختبار يحصل على BrowserContext جديد بكوكيز وتخزين خاصين، لكنه لا يعزل قاعدة البيانات. عاملان يسجلان الدخول بالمستخدم نفسه ويعدّلان المشروع نفسه سيفشلان بطريقة تبدو عشوائية.
حلّنا القياسي هو fixture على نطاق العامل يُنشأ عبر base.extend مع { scope: 'worker' }، ويوفّر حسابًا لكل عامل مرتبطًا بـ workerInfo.parallelIndex ثم يحذفه بعد الانتهاء. يُنشأ الحساب مرة واحدة لكل عملية عاملة لا لكل اختبار، فتبقى التكلفة منخفضة.
المكسب الكبير الآخر هو المصادقة. نسجّل الدخول مرة واحدة في مشروع إعداد، ونحفظ الجلسة عبر storageState، فيبدأ كل اختبار وهو مسجّل الدخول. هذا يزيل مسار تسجيل الدخول من كل اختبار، وهو أكثر مصادر التذبذب وإهدار الوقت شيوعًا في المجموعات الشاملة.
لماذا ينجح الاختبار نفسه محليًا ويفشل في CI؟
لأن CI جهاز مختلف بساعة مختلفة ومجموعة خطوط مختلفة وأنوية أقل. يشتق Playwright عدد العمال من الأنوية المتاحة، لذا فإن خادمًا بنواتين ينفّذ إعدادًا مضبوطًا على حاسوب بعشر أنوية يكون أشد ازدحامًا؛ لذلك نضبط العمال على 2 في CI ونتركه تلقائيًا محليًا.
المنطقة الزمنية واللغة هما المشكلة التالية. تاريخ يُعرض 19/09/2026 محليًا و9/19/2026 على الخادم يُفشل توكيد النص، لذا نثبّت timezoneId: 'UTC' وlocale: 'en-US' في الإعداد. كما أن غياب الخطوط يزيح التخطيط ويكسر مقارنة اللقطات، ولهذا نشغّل الاختبارات داخل صورة mcr.microsoft.com/playwright الرسمية بدل صورة Node مجردة.
سكربتات الطرف الثالث هي العامل الأخير. أدوات التحليلات وواجهات الدفع اعتماد شبكي لا يتحكم به أحد، لذا نلغيها في الاختبارات عبر page.route('**/analytics/**', route => route.abort()).
كيف نصحّح تذبذبًا لا يمكن إعادة إنتاجه محليًا؟
سجّل تتبّعًا لعملية التشغيل الفاشلة بدل محاولة إعادة إنتاجها. ضبط trace: 'on-first-retry' يكتب ملف zip يحوي لقطة DOM لكل إجراء وسجل الشبكة الكامل ومخرجات الكونسول. وتشغيل npx playwright show-trace trace.zip يفتح عارضًا زمنيًا يتيح فحص أي خطوة مقابل الـ DOM الفعلي الذي وقعت عليه النقرة.
وعند الرغبة في إعادة الإنتاج محليًا، يشغّل npx playwright test tests/checkout.spec.ts --repeat-each=50 --workers=4 الاختبار خمسين مرة تحت ازدحام، ما يكشف حالات تسابق الترتيب أسرع بكثير من إعادة تشغيل CI. ويعيد الخيار --last-failed تشغيل ما فشل سابقًا فقط. وفي المسارات المقسّمة، يعطي مُبلِّغ blob مع npx playwright merge-reports تقرير HTML واحدًا لكل الأقسام بدل تقارير جزئية متعددة.
هل نعيد المحاولة أم نعزل الاختبار المتذبذب أم نحذفه؟
إعادة المحاولة أداة إبلاغ لا إصلاح. ضبط retries على 2 في CI يبقي المسار قابلًا للاستخدام ويضع علامة flaky على الاختبار في التقرير ليبقى مرئيًا. وإن أخفت إعادة المحاولة الفشل نهائيًا، نكون قد استبدلنا اختبارًا فاشلًا باختبار بلا معنى.
أعد المحاولة عندما تكون المجموعة مستقرة في معظمها وتحتاج CI أن يبقى صالحًا؛ التكلفة إخفاء أخطاء منتج متقطعة إن لم يقرأ أحد تقرير التذبذب.
اعزل الاختبار عبر test.fixme أو وسم @flaky عندما يفشل يوميًا ويعطّل الدمج؛ التكلفة بقاء مسار المستخدم بلا تغطية حتى يعود إليه أحد.
أصلح حالة التسابق عندما يكون السبب الجذري توكيدًا ناقصًا أو بيانات مشتركة؛ يستغرق ذلك ساعة عادةً ويصلح عائلة كاملة من الاختبارات.
احذف الاختبار عندما لا يؤكد شيئًا يلاحظه المستخدم؛ التكلفة تغطية مفقودة، لكن التقرير يصبح صادقًا.
الأسئلة الشائعة
س: ما دام Playwright ينتظر تلقائيًا، فهل نحتاج انتظارًا صريحًا؟
ج: ينتظر Playwright قابلية العنصر للتفاعل قبل كل إجراء، لكنه لا ينتظر حالة التطبيق مثل اكتمال عملية على الخادم. أكّد النتيجة بتوكيد web-first مثل expect(locator).toHaveCount(4) بدل إضافة انتظار.
س: ما الفرق بين page.waitForTimeout() وexpect().toPass()؟
ج: يوقف waitForTimeout() التنفيذ مدة ثابتة بغض النظر عن الحالة، بينما يعيد expect().toPass() محاولة كتلة توكيدات حتى تنجح كلها أو تنتهي المهلة، فينتهي فور تحقق الشرط.
س: هل تُشغَّل الاختبارات الشاملة على قاعدة بيانات حقيقية؟
ج: نعم لأجل الثقة، لكن مع عزل على مستوى العامل. استخدم fixture على نطاق العامل مرتبطًا بـ testInfo.parallelIndex كي لا يلمس عاملان الصفوف نفسها.
س: كم عدد الاختبارات الشاملة المناسب للمشروع؟
ج: عدد قليل بما يكفي لتشغيله مع كل طلب دمج. غطِّ مسارات المصادقة والإيرادات بالكامل، وادفع ما عداها إلى اختبارات المكوّنات والوحدات، فهي أسرع وأقل عرضة للتذبذب.
س: هل يبطئ trace: 'on' المجموعة؟
ج: نعم، فتسجيل كل عملية تشغيل يضيف عبئًا زمنيًا واستهلاكًا للقرص. أما trace: 'on-first-retry' فيمنح القدرة التصحيحية نفسها لكن على العمليات التي فشلت بالفعل فقط.
قراءات إضافية
الاختبارات
اختبارات الانحدار البصري باستخدام toHaveScreenshot في Playwright: كيف نثبّت الصور المرجعية داخل Docker ونخفي المحتوى المتغير ونستخدم لقطات شجرة الوصول
فشلت الصور المرجعية التي ولّدناها لاختبارات لقطات الشاشة على جهاز Mac عند تشغيلها على CI بنظام Linux دون تغيير سطر واحد من الكود. نشارك هنا كيف نشغّل toHaveScreenshot() في Playwright بشكل موثوق: صور مرجعية تُولَّد داخل صورة Docker مثبتة الإصدار، وتجميد الوقت وإخفاء المحتوى المتغير، وهامش اختلاف قريب من الصفر، ولقطات aria للتحقق من البنية.
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.