كانت اختباراتنا تنهار عشوائياً: كيف أنقذنا Playwright من جحيم الاختبارات المتقشرة (Flaky Tests)؟

يا مساء الخير يا جماعة، أبو عمر معكم.

بتذكر هذيك الليلة جيداً. الساعة كانت حوالي ٢ بعد منتصف الليل، وأنا وفريق العمل قاعدين في المكتب، والقهوة ما عادت تجيب نتيجة. كان المفروض نطلق تحديث جديد ومهم للمنصة، لكن الـCI/CD pipeline كان أحمر يصرخ في وجوهنا. المشكلة؟ اختبارات الـ End-to-End المكتوبة بـ Playwright.

كنا نعيد تشغيل الـ pipeline، فتنجح ٥٠ اختباراً وتفشل ٣. نعيد التشغيل مرة ثانية، فتنجح نفس الـ ٣ اختبارات اللي فشلت، ويفشل اختباران آخران كانا يعملان بشكل ممتاز قبل دقيقتين. شعور بالإحباط لا يوصف. كأن هناك “جنّي” في السيرفر يتلاعب بنتائج الاختبارات. هذه الظاهرة، يا أصدقائي، هي ما نسميه في عالم البرمجة بـ “الاختبارات المتقشرة” أو “Flaky Tests”، وهي كابوس كل مطور.

في تلك الليلة، قررنا أن نوقف كل شيء ونعلن الحرب على هذا “التقشر”. واليوم، سأشارككم ما تعلمناه في هذه المعركة، وكيف حولنا مجموعة اختباراتنا من مصدر إزعاج إلى شبكة أمان حقيقية لمشروعنا.

ما هي الاختبارات المتقشرة (Flaky Tests) ولماذا هي شيطان في ثوب ملاك؟

ببساطة، الاختبار المتقشر هو اختبار ينجح أحياناً ويفشل أحياناً أخرى عند تشغيله على نفس الكود البرمجي وبدون أي تغييرات. هو اختبار “مزاجي”، لا يمكنك الاعتماد على نتيجته.

قد تبدو المشكلة بسيطة، لكن خطورتها تكمن في أنها تدمر أهم شيء في عملية الاختبار: الثقة.

  • تآكل الثقة: عندما يبدأ الفريق بالقول “آه، تجاهل هذا الفشل، إنه مجرد اختبار متقشر”، تكون قد بدأت الكارثة. في هذه اللحظة، قد يتسلل خطأ حقيقي (Bug) إلى الإنتاج لأن الجميع اعتاد على تجاهل الاختبارات الفاشلة.
  • إهدار الوقت: كم من ساعات قضيناها في إعادة تشغيل الـ pipelines والتحقيق في فشل وهمي؟ هذا وقت كان من الممكن استغلاله في بناء ميزات جديدة أو إصلاح مشاكل حقيقية.
  • إبطاء عملية التطوير: عندما لا تثق في اختباراتك، تصبح عملية الدمج (Merging) والإطلاق (Deployment) بطيئة ومؤلمة وتتطلب تدخلاً يدوياً، وهذا عكس الهدف من الأتمتة.

باختصار، مجموعة اختبارات متقشرة أسوأ من عدم وجود اختبارات على الإطلاق. على الأقل في الحالة الثانية، أنت تعرف أنك بحاجة إلى الاختبار اليدوي. أما في الحالة الأولى، فأنت تعيش في وهم الأمان.

تشخيص الأسباب الجذرية: رحلتنا في البحث عن “الجني”

بعد تلك الليلة، بدأنا عملية تشريح منهجية لاختباراتنا. اكتشفنا أن “الجني” لم يكن سوى مجموعة من الأخطاء الشائعة التي وقعنا فيها. إليكم أبرز الأعداء الذين واجهناهم وكيف تغلبنا عليهم.

العدو الأول: مشاكل التوقيت والانتظار (Timing and Waiting Issues)

كان هذا أكبر خطأ ارتكبناه. في البداية، عندما كان الاختبار يفشل لأنه لم يجد عنصراً ما، كان الحل السريع هو إضافة انتظار ثابت.

“العنصر لم يظهر بعد؟ بسيطة، خلينا نضيف page.waitForTimeout(2000)“.

هذا الحل هو السم الزعاف للاختبارات الآلية. لماذا؟

  1. إذا كان الانتظار قصيراً جداً: في بيئة الاختبار المحلية السريعة، قد يكون ثانيتان كافياً. لكن على سيرفر الـCI البطيء أو تحت الضغط، قد يحتاج العنصر إلى 2.5 ثانية للظهور، فيفشل الاختبار. هذا هو جوهر “التقشر”.
  2. إذا كان الانتظار طويلاً جداً: إذا كان العنصر يظهر خلال 100 ميلي ثانية، فإن انتظارك لثانيتين هو إهدار لـ 1.9 ثانية من عمرك في كل مرة يعمل فيها الاختبار. اضرب هذا في 100 اختبار، وستجد أنك تهدر دقائق ثمينة.

الحل: دع Playwright يقوم بالانتظار (Auto-Waiting)

من أجمل ميزات Playwright هي آلية الانتظار التلقائي (Auto-waiting). معظم الأوامر مثل click(), fill(), و expect(locator).toBeVisible() تنتظر تلقائياً حتى يصبح العنصر جاهزاً (موجوداً في الـ DOM، مرئياً، ومفعّلاً) قبل تنفيذ الإجراء أو التحقق.

مثال عملي:

الكود السيء (المتقشر):


// لا تفعل هذا أبداً!
await page.getByRole('button', { name: 'حفظ' }).click();
await page.waitForTimeout(2000); // خطيئة كبرى
await expect(page.locator('#success-message')).toBeVisible();

الكود الصحيح (الصلب):


await page.getByRole('button', { name: 'حفظ' }).click();

// Playwright سينتظر تلقائياً ظهور هذه الرسالة حتى المهلة المحددة (الافتراضي 5 ثوان)
await expect(page.locator('#success-message')).toBeVisible();

// يمكنك زيادة المهلة إذا لزم الأمر لحالات معينة
await expect(page.locator('#long-loading-element')).toBeVisible({ timeout: 10000 });

نصيحة أبو عمر: احذف كل waitForTimeout من الكود الخاص بك اليوم. عاملها كأنها خطأ في الكود (linting error). ثق بآليات الانتظار الذكية في Playwright، فهي صديقك الصدوق.

العدو الثاني: الاعتماد على حالة خارجية (Dependency on External State)

كانت بعض اختباراتنا تعتمد على بعضها البعض. مثلاً، اختبار “إنشاء مستخدم جديد” يجب أن يعمل قبل اختبار “تسجيل الدخول”. إذا فشل الأول، يفشل الثاني تلقائياً. وإذا تم تشغيلها بالتوازي، تحدث فوضى عارمة.

الاختبار الجيد يجب أن يكون مستقلاً، منعزلاً، وقابلاً للتكرار بغض النظر عن أي شيء آخر.

الحل: عزل الاختبارات (Test Isolation)

يجب على كل اختبار أن يقوم بتهيئة بيئته الخاصة قبل أن يبدأ، وتنظيفها بعد أن ينتهي. هذا يضمن أن كل اختبار هو عالم قائم بذاته.

مثال عملي باستخدام test.beforeEach:


import { test, expect } from '@playwright/test';

// يمكن وضع هذا في ملف إعداد عام
test.beforeEach(async ({ page }) => {
  // الخطوة 1: قم بإنشاء البيانات اللازمة عبر API (أسرع وأنظف)
  // بدلاً من المرور بواجهة إنشاء مستخدم في كل اختبار
  const userData = await api.createUserForTest();

  // الخطوة 2: قم بتسجيل الدخول برمجياً أو عبر الواجهة
  await page.goto('/login');
  await page.getByLabel('البريد الإلكتروني').fill(userData.email);
  await page.getByLabel('كلمة المرور').fill(userData.password);
  await page.getByRole('button', { name: 'تسجيل الدخول' }).click();
  
  // تأكد من أنك في الصفحة الصحيحة قبل بدء الاختبار الفعلي
  await expect(page).toHaveURL('/dashboard');
});

// يمكن إضافة test.afterEach لحذف المستخدم من قاعدة البيانات

test('يجب أن يتمكن المستخدم من تحديث ملفه الشخصي', async ({ page }) => {
  await page.goto('/profile');
  await page.getByLabel('الاسم الأول').fill('عمر');
  await page.getByRole('button', { name: 'حفظ' }).click();
  await expect(page.locator('.toast-success')).toBeVisible();
});

test('يجب أن يتمكن المستخدم من تسجيل الخروج', async ({ page }) => {
  await page.getByRole('button', { name: 'تسجيل الخروج' }).click();
  await expect(page).toHaveURL('/login');
});

نصيحة أبو عمر: فكر في كل اختبار على أنه جزيرة منعزلة. قبل أن يصل الزائر (الكود)، قم ببناء الجزيرة (beforeEach)، وعندما يغادر، قم بهدمها (afterEach). هذا يضمن أن تجربة كل زائر جديدة ونظيفة تماماً.

العدو الثالث: محددات العناصر غير المستقرة (Unstable Selectors)

في البداية، كنا نستخدم أي محدد يعمل. أحياناً XPath طويل ومعقد، وأحياناً أسماء كلاسات CSS التي تولدها المكتبات مثل .css-1t5g8f4. هذه المحددات هشة جداً، أي تغيير بسيط في تصميم الواجهة من قبل المطورين كان يكسر عشرات الاختبارات.

الحل: استخدام محددات موجهة للمستخدم (User-Facing Selectors)

يوصي Playwright باستخدام محددات تعكس كيفية تفاعل المستخدم الفعلي مع الصفحة. هذا يجعل اختباراتك أكثر قوة وأقل عرضة للكسر مع تغييرات التصميم.

هرم أفضلية المحددات:

  1. page.getByRole(): الأفضل والأكثر متانة. يبحث عن العناصر بناءً على دورها (مثل ‘button’, ‘heading’, ‘link’).
  2. page.getByText(): للعثور على العناصر التي تحتوي على نص معين.
  3. page.getByLabel(): ممتاز لحقول الإدخال المرتبطة بملصق (label).
  4. page.getByTestId(): الملاذ الأخير الآمن. اتفق مع فريقك على إضافة سمة data-testid="some-unique-id" للعناصر المهمة التي يصعب تحديدها بالطرق الأخرى.

مثال عملي:

الكود السيء (الهش):


// سيئ جداً، سيكسر مع أي تغيير في البنية
await page.locator('div.container > div:nth-child(2) > section > button').click();

// سيئ، سيكسر إذا تغيرت كلاسات الـ CSS
await page.locator('.btn.btn-primary.mt-2').click();

الكود الصحيح (المتين):


// جيد جداً، يعتمد على ما يراه المستخدم
await page.getByRole('button', { name: 'إرسال الطلب' }).click();

// بديل ممتاز إذا كان النص فريداً
await page.getByText('موافق').click();

// الحل النهائي للعناصر الصعبة
// في الكود: <button data-testid="main-submit-button">إرسال</button>
await page.getByTestId('main-submit-button').click();

نصيحة أبو عمر: اكتب اختباراتك وكأنك مستخدم كفيف يعتمد على قارئ الشاشة. هو لا يرى الألوان أو التنسيق، بل يرى “زر اسمه تسجيل الدخول” و “رابط اسمه اتصل بنا”. هذا التفكير سيجبرك على كتابة اختبارات قوية.

العدو الرابع: تجاهل سباق البيانات والشبكة (Ignoring Network Race Conditions)

هذا سبب خفي ومزعج جداً. يحدث عندما يتفاعل اختبارك مع الواجهة بشكل أسرع من تواصلها مع الخادم.

السيناريو:

  1. الاختبار يضغط على زر “حفظ”.
  2. الواجهة الأمامية ترسل طلب API إلى الخادم.
  3. الاختبار يتحقق فوراً من ظهور رسالة “تم الحفظ بنجاح”.
  4. المشكلة: طلب الـ API لم يكتمل بعد، والرسالة لم تظهر. يفشل الاختبار. في المحاولة التالية، يكون الخادم أسرع قليلاً، فينجح الاختبار. هذا هو التقشر بعينه.

الحل: انتظار استجابات الشبكة (Waiting for Network Responses)

بدلاً من انتظار عنصر في الواجهة، يمكنك جعل الاختبار ينتظر اكتمال طلب الشبكة الفعلي. هذا يضمن أن الحالة متزامنة بين الواجهة والخادم.

مثال عملي:


test('يجب انتظار اكتمال طلب الـ API قبل التحقق', async ({ page }) => {
  await page.goto('/some-form');
  await page.getByLabel('اسم المنتج').fill('منتج جديد');

  // هنا السحر: انتظر شيئين في نفس الوقت
  // 1. انتظر استجابة الشبكة من الـ API endpoint الصحيح
  // 2. قم بتنفيذ الإجراء الذي يطلق هذا الطلب (الضغط على الزر)
  await Promise.all([
    page.waitForResponse(resp => 
      resp.url().includes('/api/products') && resp.status() === 201
    ),
    page.getByRole('button', { name: 'إضافة منتج' }).click()
  ]);

  // الآن، بعد أن تأكدنا 100% أن الخادم استجاب بنجاح،
  // يمكننا التحقق من النتيجة في الواجهة بثقة تامة.
  await expect(page.locator('.notification-success')).toHaveText('تمت إضافة المنتج بنجاح!');
});

نصيحة أبو عمر: الواجهة الأمامية والخلفية مثل شخصين يرقصان دبكة، يجب أن تكون خطواتهما متناسقة. في اختباراتنا، مهمتنا هي أن نكون “قائد الدبكة” الذي يتأكد أن الخطوات متزامنة قبل الانتقال للحركة التالية.

استراتيجيات Playwright المتقدمة: التتبع وإعادة المحاولة

بعد تطبيق كل الإصلاحات السابقة، انخفضت نسبة التقشر بشكل هائل. لكن للقضاء على البقايا، استخدمنا سلاحين فتاكين من ترسانة Playwright.

1. إعادة المحاولة (Retries)

في ملف playwright.config.js، يمكنك تفعيل إعادة المحاولة للاختبارات الفاشلة.


// playwright.config.js
export default {
  retries: process.env.CI ? 2 : 0, // أعد المحاولة مرتين في بيئة CI، ولا مرة محلياً
};

تحذير مهم: إعادة المحاولة هي شبكة أمان، وليست حلاً. هي تمنع فشل الـ pipeline بسبب تقشر عشوائي ونادر. لكن إذا رأيت اختباراً يفشل ثم ينجح في إعادة المحاولة، فهذا يعني أنه لا يزال متقشراً ويجب عليك إصلاحه!

2. التتبع (Tracing): السلاح السري

هذه الميزة غيرت قواعد اللعبة بالنسبة لنا. التتبع يسجل كل شيء حدث أثناء تشغيل الاختبار: لقطات DOM لكل خطوة، طلبات الشبكة، سجلات الـ console، وحركات الماوس. إنه الصندوق الأسود لاختبارك.

نقوم بتفعيله ليشغل فقط عند أول إعادة محاولة، حتى لا يبطئ الاختبارات الناجحة.


// playwright.config.js
export default {
  use: {
    trace: 'on-first-retry',
  },
};

عندما يفشل اختبار على الـ CI، يتم حفظ ملف تتبع (trace.zip). يمكنك تنزيله وفتحه بأمر بسيط: npx playwright show-trace trace.zip. ستفتح لك واجهة مذهلة تريك القصة الكاملة للفشل، خطوة بخطوة. لقد وفرت علينا هذه الميزة ساعات لا تحصى من التحقيق والتخمين.

الخلاصة: روشتة أبو عمر لاختبارات حديدية 🦾

رحلتنا مع الاختبارات المتقشرة كانت طويلة ومحبطة في البداية، لكنها كانت مجزية للغاية في النهاية. اليوم، نحن نثق في مجموعة اختباراتنا، ونطلق تحديثات جديدة بثقة وسرعة. إليكم خلاصة ما تعلمناه:

  • 🚫 اقتل الانتظار الثابت: تخلص من waitForTimeout واستخدم آليات الانتظار التلقائي الذكية في Playwright.
  • 🏝️ اعزل اختباراتك: استخدم beforeEach و afterEach لضمان أن كل اختبار هو عالم مستقل.
  • 👤 فكر كالمستخدم: استخدم محددات قوية وموجهة للمستخدم مثل getByRole و getByTestId.
  • 🌐 احترم الشبكة: في العمليات المهمة، انتظر استجابات الـ API بدلاً من التخمين.
  • 🕵️ استخدم أدواتك: فعّل إعادة المحاولة كشبكة أمان، واستخدم التتبع (Tracing) كأداة تحقيق لا تقدر بثمن لتشخيص الفشل.

تذكروا يا جماعة، بناء مجموعة اختبارات موثوقة هو ماراثون وليس سباق سرعة. يتطلب الأمر انضباطاً وجهداً مستمراً، لكن راحة البال والثقة التي يمنحك إياها فريقك تساوي كل ثانية من هذا الجهد. يلا شدوا حيلكم، وخلينا نكتب كود نظيف واختبارات أقوى.

أبو عمر

سجل دخولك لعمل نقاش تفاعلي

كافة المحادثات خاصة ولا يتم عرضها على الموقع نهائياً

آراء من النقاشات

لا توجد آراء منشورة بعد. كن أول من يشارك رأيه!

آخر المدونات

ادارة الفرق والتنمية البشرية

المسار الوظيفي المزدوج: كيف أنقذنا خيرة مهندسينا من جحيم الاختيار بين الإدارة والكود؟

كان مهندسونا يغادرون صمتاً، واحداً تلو الآخر. لم تكن المشكلة في الراتب أو في المشروع، بل في سقف زجاجي يجبرهم على الاختيار بين شغفهم بالكود...

14 مايو، 2026 قراءة المزيد
أدوات وانتاجية

كانت طرفيتي سجناً: كيف أنقذنا ‘الباحث التقريبي’ (Fuzzy Finder) من جحيم البحث في الـ History؟

أشارككم تجربتي الشخصية مع أداة fzf، الباحث التقريبي الذي حوّل طرفيتي من سجن للأوامر المتكررة إلى بيئة عمل فائقة الإنتاجية. اكتشفوا كيف يمكن لهذه الأداة...

14 مايو، 2026 قراءة المزيد
أتمتة العمليات

كانت عمليات النشر كابوساً: كيف أنقذتنا “خطوط أنابيب CI/CD” من جحيم “يوم النشر” اليدوي؟

أنا أبو عمر، مبرمج فلسطيني، وأروي لكم كيف انتقلنا من ليالي النشر اليدوي المليئة بالتوتر والأخطاء إلى عالم الأتمتة والثقة باستخدام خطوط أنابيب CI/CD. هذه...

14 مايو، 2026 قراءة المزيد
خوارزميات

كان البحث عن ‘الأماكن القريبة’ يقتل الأداء: كيف أنقذتنا ‘شجرة الكواد’ (Quadtree) من جحيم البحث الخطي؟

قصة حقيقية من قلب المعركة البرمجية، كيف انتقلنا من تطبيق بطيء ينهار تحت ضغط البحث عن الأماكن القريبة إلى أداء صاروخي بفضل خوارزمية "شجرة الكواد"....

14 مايو، 2026 قراءة المزيد
تجربة المستخدم والابداع البصري

كان تطبيقنا سجنًا رقميًا: كيف أنقذتنا ‘إمكانية الوصول’ (Accessibility) من جحيم استبعاد المستخدمين؟

أشارككم قصة حقيقية من مسيرتي كمطور، قصة كيف تحول تطبيق كنا نفخر به إلى سجن رقمي استبعد فئة كبيرة من المستخدمين. هذه المقالة ليست مجرد...

14 مايو، 2026 قراءة المزيد
البودكاست