اختبارات الانحدار البصري باستخدام toHaveScreenshot في Playwright: كيف نثبّت الصور المرجعية داخل Docker ونخفي المحتوى المتغير ونستخدم لقطات شجرة الوصول
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: تقارن toHaveScreenshot() في Playwright الصفحة أو العنصر بصورة مرجعية محفوظة في المستودع، ولا تبقى موثوقة إلا إذا وُلّدت الصور المرجعية وقورنت داخل صورة Docker نفسها المثبتة الإصدار، وجُمّد المحتوى المتغير أو أُخفي، وبقي هامش الاختلاف قريبًا من الصفر. استخدم toMatchAriaSnapshot() للتحقق من البنية، واترك لقطات البكسل لما لا تكشفه إلا البكسلات: التخطيط والألوان وانعكاس الواجهة من اليمين إلى اليسار.
اختبار الانحدار البصري (Visual Regression Testing) يقارن لقطة شاشة جديدة للواجهة بصورة مرجعية معتمدة، ويفشل عندما تتغير البكسلات. وهو يلتقط الأخطاء التي تفوتها الاختبارات الوظيفية: تغيير في الحشو (padding) داخل زر مشترك يدفع بطاقة أسعار إلى سطر ثانٍ، أو أيقونة لا تنعكس في تخطيط عربي من اليمين إلى اليسار، أو خط بديل يكسر عنوانًا على سطرين. نحن نستخدم تأكيدات لقطات الشاشة المدمجة في Playwright Test، وكان الدرس الأول عن بيئة العرض لا عن CSS: الصور المرجعية التي وُلّدت على جهاز Mac لأحد المطورين فشلت على CI العامل بنظام Linux دون تغيير سطر واحد من الكود.
أهم النقاط
• اختبار الانحدار البصري يقارن لقطة شاشة جديدة بصورة مرجعية معتمدة ويفشل عند اختلاف البكسلات، وPlaywright Test يقوم بذلك عبر await expect(page).toHaveScreenshot() دون أي خدمة إضافية.
• الصور المرجعية مرتبطة بنظام التشغيل: رسم الخطوط وتنعيم الحواف (anti-aliasing) يختلفان بين macOS وLinux، لذا ولّد الصور المرجعية وقارنها داخل صورة Docker الرسمية mcr.microsoft.com/playwright نفسها وبإصدار مثبت.
• toHaveScreenshot() تعطّل حركات CSS وتخفي مؤشر الكتابة افتراضيًا، أما الساعة والبيانات الحية والصور الرمزية والعناصر المضمّنة فتحتاج إلى page.clock وpage.route() وmask وstylePath.
• الخياران maxDiffPixels وmaxDiffPixelRatio غير مضبوطين افتراضيًا، ونسبة 1% على نافذة بحجم 1280×720 تسمح بتغيّر 9,216 بكسل، وهذا يكفي لإخفاء أيقونة مفقودة.
• toMatchAriaSnapshot() تتحقق من شجرة الوصول (accessibility tree) بصيغة YAML وتبقى ثابتة عبر أنظمة التشغيل، فهي تغطي البنية بينما تغطي لقطات الشاشة المظهر.
ما الذي تقارنه toHaveScreenshot() في Playwright فعلًا؟
تقارن toHaveScreenshot() الصفحة أو العنصر الحالي بصورة مرجعية محفوظة بجوار ملف الاختبار، بعد التقاط لقطات متتالية حتى تتطابق لقطتان متتاليتان. في التشغيل الأول لا توجد صورة مرجعية، فيكتب Playwright الصورة الفعلية ويُفشل الاختبار برسالة A snapshot doesn't exist at …, writing actual.
تعتمد المقارنة على pixelmatch، وهي مكتبة لمقارنة الصور. الخيار threshold فيها هو فرق اللون المُدرَك المسموح به لكل بكسل، ويُقاس في فضاء الألوان YIQ (نموذج يفصل السطوع عن اللون)، من 0 (صارم) إلى 1 (متساهل)، وقيمته الافتراضية 0.2. بعد ذلك يحدد maxDiffPixels وmaxDiffPixelRatio عدد البكسلات المسموح لها بتجاوز هذا الحد.
أول اختبار نكتبه لأي صفحة نوسمه بالوسم @visual، وينتظر ظهور العنوان الرئيسي، ثم يستدعي toHaveScreenshot("pricing.png") على الصفحة كاملة وtoHaveScreenshot("plan-cards.png") على عنصر بطاقات الخطط.
نفضّل لقطات العناصر عبر expect(locator).toHaveScreenshot() على لقطات الصفحة الكاملة. الصورة الأصغر تعني أن الفرق الفاشل يشير إلى مكوّن واحد. الصور المرجعية بصيغة PNG افتراضيًا، ويحفظ Playwright 1.64 أيضًا بصيغة WebP بلا فقدان عندما ينتهي اسم اللقطة بـ .webp.
لماذا تنجح لقطات Playwright على Mac وتفشل في CI؟
يختلف عرض المتصفح بين أنظمة التشغيل، لذلك لا تطابق صورة مرجعية التُقطت على macOS لقطة مأخوذة على CI بنظام Linux حتى لو لم يتغير شيء. توثيق Playwright يذكر نظام التشغيل وإصدار المتصفح والعتاد ووضع التشغيل دون واجهة (headless) كعوامل مؤثرة. وعمليًا تظهر صورة الفرق كحدّ باهت حول كل حرف: إنه رسم الخطوط وتنعيم الحواف، لا التخطيط.
التسمية الافتراضية للملفات في Playwright تجعل ذلك ظاهرًا. اسم الصورة المرجعية يتضمن المتصفح أو المشروع ونظام التشغيل، مثل pricing-chromium-darwin.png، فتكون الصورة المولّدة على Mac ببساطة مفقودة عندما يبحث CI على Linux عن pricing-chromium-linux.png.
الحل هو بيئة عرض واحدة: صورة Docker الرسمية mcr.microsoft.com/playwright، مثبتة على الإصدار نفسه من @playwright/test الموجود في package.json. ويحذّر توثيق Playwright من أن اختلاف الإصدارين يجعله عاجزًا عن العثور على ملفات المتصفحات التنفيذية. الصور المرجعية تُولَّد داخل هذه الصورة، وCI يعمل داخل هذه الصورة.
لإعادة توليد الصور المرجعية محليًا نشغّل docker run --rm --ipc=host على الصورة mcr.microsoft.com/playwright:v1.64.1-noble مع ربط المستودع على المسار /work ووحدة تخزين مجهولة للمسار /work/node_modules، ثم ننفذ داخلها npm ci ثم npx playwright test --grep @visual --update-snapshots.
وحدة التخزين المجهولة -v /work/node_modules تُبقي تثبيت Linux داخل الحاوية بعيدًا عن مجلد node_modules الخاص بنظام macOS على جهاز المطور، والخيار --ipc=host يتبع توصية Playwright لتشغيل Chromium داخل Docker، إذ قد تنفد الذاكرة المشتركة بدونه. ولأن Linux وحده يكتب الصور المرجعية، نحذف الرمز {platform} من مسار اللقطات. كما أننا نلتقط الشاشة من نسخة إنتاجية مبنية فقط، لأن next dev يضيف مؤشر التطوير الخاص بـ Next.js ويترجم المسارات عند الطلب.
إعدادات Playwright لدينا تضبط snapshotPathTemplate على {testDir}/__screenshots__/{testFilePath}/{arg}{ext}، وتوجّه stylePath إلى ملف أنماط خاص باللقطات، وتثبّت locale وtimezoneId وcolorScheme، وتشغّل التطبيق عبر npm run build && npm run start بوصفه webServer.
في GitHub Actions تعمل مهمة الاختبارات البصرية داخل الصورة نفسها عبر المفتاح container مع options: --ipc=host، وتنفذ npm ci ثم npx playwright test --project=visual، وترفع مجلد playwright-report/ عند فشل المهمة.
كيف نجعل المحتوى المتغير ثابتًا بما يكفي لالتقاط الشاشة؟
جمّد كل ما يتغير بشكل مشروع بين التشغيلات قبل التأكيد: الوقت والحركة وحالة التمرير بالمؤشر (hover) والبيانات والعناصر المضمّنة من أطراف خارجية. toHaveScreenshot() تتكفل باثنين افتراضيًا: الخيار animations: "disabled" يقدّم حركات CSS والانتقالات المحدودة إلى نهايتها ويعيد الحركات اللانهائية إلى حالتها الأولى، والخيار caret: "hide" يخفي مؤشر الكتابة الوامض.
• الوقت: page.clock.setFixedTime() تثبّت Date.now() وnew Date()، فتظهر الأوقات النسبية وسنة التذييل بالشكل نفسه في كل تشغيل، بينما يثبّت timezoneId وlocale في الإعدادات تنسيق التواريخ والأرقام.
• البيانات: page.route() تجيب طلبات الـ API ببيانات JSON ثابتة، فلا تنحرف الأعداد والأسماء والحالات الفارغة.
• المناطق المتغيرة: الخيار mask يستقبل محددات (locators) ويرسم مربعًا بلون #FF00FF فوق الإطار المحيط بكل عنصر (ويغيّر maskColor هذا اللون)، بينما يبقى التخطيط المحيط به خاضعًا للمقارنة.
• العناصر المضمّنة واللافتات: stylePath يطبّق ملف أنماط أثناء التقاط الشاشة فقط، وهذا الملف يخترق Shadow DOM ويُطبَّق داخل الإطارات (iframes).
• التمرير بالمؤشر: اللقطات تلتقط حالة hover الحالية، لذا نحرّك المؤشر خارج الصفحة أولًا.
• الخطوط: الخطوط المستضافة ذاتيًا، التي يوفرها next/font، تمنع الحاوية من الرجوع إلى خط النظام. وفي الصفحات العربية يغيّر الخط البديل تشكيل الحروف وكسر الأسطر في الصفحة كلها.
اختبار لوحة التحكم يستدعي page.clock.setFixedTime() بتاريخ ثابت، ويجيب واجهة الإشعارات بقائمة فارغة عبر page.route()، ويحرّك المؤشر خارج الصفحة، ويخفي الصورة الرمزية للمستخدم والرسم البياني الحي.
ملف أنماط اللقطات يضبط visibility: hidden على الإطارات (iframes) ولافتة ملفات تعريف الارتباط وأداة الدردشة.
الخاصية visibility: hidden هي الصحيحة في هذا الملف لأنها تُبقي مساحة العنصر، فيبقى كل ما تحته في مكانه. أما display: none فتُلغي هذه المساحة وتُزيح التخطيط نفسه الذي نحاول اختباره.
ما مقدار اختلاف البكسلات الذي يجب أن يتسامح معه اختبار اللقطات؟
بعد تثبيت بيئة العرض داخل Docker يجب أن يكون هامش التسامح قريبًا من الصفر، لأنه موجود لامتصاص ضوضاء تنعيم الحواف لا التغييرات الحقيقية. يوفّر Playwright ثلاثة خيارات يمكن ضبطها لكل تأكيد أو تحت expect.toHaveScreenshot في الإعدادات:
• threshold: فرق اللون المُدرَك المسموح به لكل بكسل، من 0 (صارم) إلى 1 (متساهل)؛ القيمة الافتراضية 0.2.
• maxDiffPixels: العدد المطلق للبكسلات المسموح لها بتجاوز الحد؛ غير مضبوط افتراضيًا.
• maxDiffPixelRatio: نسبة البكسلات من الإجمالي المسموح لها بتجاوز الحد، من 0 إلى 1؛ غير مضبوط افتراضيًا.
الخيار maxDiffPixelRatio يكبر مع حجم الصورة، ولهذا نتجنبه. نسبة 0.01 على نافذة Desktop Chrome بحجم 1280×720 تسمح بتغيّر 9,216 بكسل، وعلى لقطة صفحة كاملة بحجم 1280×5000 تسمح بـ 64,000 بكسل. وكلا الهامشين قادر على إخفاء أيقونة مفقودة أو سطر نص انكسر. نحن نُبقي threshold على قيمته الافتراضية، ونترك هوامش البكسلات غير مضبوطة على المستوى العام، ونضيف قيمة صغيرة لـ maxDiffPixels إلى تأكيد واحد فقط بعد قراءة صورة الفرق والتأكد من أن الضوضاء ناتجة عن تنعيم الحواف.
متى نستخدم لقطة aria بدلًا من لقطة الشاشة؟
استخدم toMatchAriaSnapshot() عندما يكون الانحدار الذي تخشاه بنيويًا، وtoHaveScreenshot() عندما يكون بصريًا. لقطة aria هي وصف بصيغة YAML لشجرة الوصول: الأدوار والأسماء المتاحة والسمات التي تقرؤها التقنيات المساعدة. وهي تتجاهل الخطوط وطريقة العرض، فتنجح على macOS وLinux على حد سواء ولا تحتاج إلى Docker.
لقطة aria تتحقق من أن شريط التنقل الرئيسي يحتوي على رابط Pricing يشير إلى /pricing وزر Sign in.
• toHaveScreenshot(): تكشف المسافات والألوان والفيض (overflow) والخطوط البديلة وانعكاس الواجهة من اليمين إلى اليسار؛ لا يفوتها شيء بصري لكن كل فرق يحتاج إلى مراجعة بشرية؛ ليست ثابتة عبر أنظمة التشغيل لذا ثبّت صورة Docker.
• toMatchAriaSnapshot(): تكشف عناصر التحكم المفقودة أو التي تغيّر اسمها ومستويات العناوين ووجهات الروابط؛ تفوتها أنماط CSS والتخطيط والألوان؛ ثابتة عبر أنظمة التشغيل.
• toHaveText() وtoBeVisible(): تكشف قيمة أو حالة واحدة محددة؛ يفوتها كل ما لم يُؤكَّد عليه؛ ثابتة عبر أنظمة التشغيل.
تبقى البكسلات الوسيلة الوحيدة لفحص التخطيطات من اليمين إلى اليسار. في المنتجات ثنائية اللغة نلتقط كل مكوّن باللغتين، لأن سهمًا لم ينعكس أو margin-left كان يجب أن يكون margin-inline-start يبدو خاطئًا دون أن يتغير دور أو اسم واحد.
حلقة تمر على اللغتين en وar تولّد اختبارًا لكل لغة، ويلتقط كل اختبار بطاقات الخطط في صورة مرجعية خاصة به.
خدمات المراجعة البصرية المستضافة مثل Chromatic وPercy وArgos تعرض الصفحات في متصفحات خاضعة لتحكمها وتضيف واجهة اعتماد لكل لقطة، فتُغني عن خطوة Docker مقابل اشتراك مدفوع. نحن نلتزم بالتأكيد المدمج لأن الصور المرجعية تعيش في git بجوار الكود الذي يغيّرها.
كيف نحدّث الصور المرجعية دون اعتماد أخطاء بصرية؟
حدّث الصور المرجعية داخل صورة Docker المثبتة فقط، وللّقطات التي فشلت فقط، وراجع كل صورة متغيرة في طلب الدمج (pull request) كما تراجع الكود. الخيار --update-snapshots، واختصاره -u، يقبل خمسة أوضاع:
• default (دون الخيار): تُكتب الصور المرجعية المفقودة لكن تفشل تلك الاختبارات، فلا يمكن لتشغيل CI أن ينجح باعتماد مخرجاته بنفسه.
• changed (الخيار دون قيمة): يعيد كتابة الصور المرجعية التي لم تتطابق وينشئ المفقودة.
• missing: ينشئ الصور المرجعية المفقودة ويترك تلك الاختبارات تنجح.
• all: يعيد كتابة كل صورة مرجعية في الاختبارات المنفذة، حتى المتطابقة منها. بهذه الطريقة تُعتمد التغييرات غير المقصودة دون انتباه، لذا لا نستخدمه أبدًا.
• none: لا يكتب أي صورة مرجعية.
عندما يفشل تأكيد لقطة شاشة، يعرض تقرير Playwright بصيغة HTML الصور المتوقعة والفعلية وصورة الفرق جنبًا إلى جنب، وهذا ما نقرؤه قبل الاختيار بين إصلاح CSS وتحديث الصورة المرجعية. ثم تعرض واجهة مقارنة الصور في GitHub كل صورة مرجعية متغيرة داخل طلب الدمج. قاعدتنا الوحيدة: تحديث الصورة المرجعية يُدمج في الـ commit نفسه مع تغيير CSS الذي تسبب فيه.
الأسئلة الشائعة
س: هل يمكن إعادة استخدام صور Playwright المرجعية المولّدة على macOS في CI على Linux؟
ج: ليس بشكل موثوق. رسم الخطوط وتنعيم الحواف يختلفان بين أنظمة التشغيل، ولهذا يضيف Playwright اسم النظام إلى أسماء الصور المرجعية افتراضيًا؛ ولّد الصور المرجعية وقارنها داخل صورة Docker الرسمية mcr.microsoft.com/playwright نفسها وبإصدار مثبت.
س: هل تنتظر toHaveScreenshot() انتهاء الحركات؟
ج: إنها تعطّلها. مع القيمة الافتراضية animations: "disabled" تُقدَّم حركات CSS والانتقالات المحدودة إلى نهايتها وتُعاد الحركات اللانهائية إلى بدايتها، ويعيد التأكيد المحاولة حتى تتطابق لقطتان متتاليتان.
س: هل تُغني toMatchAriaSnapshot() عن اختبارات لقطات الشاشة؟
ج: لا. لقطة aria تتحقق من الأدوار والأسماء المتاحة والبنية، فتكشف زرًا مفقودًا لكنها لا تكشف تخطيطًا مكسورًا أو لونًا خاطئًا أو واجهة لم تنعكس من اليمين إلى اليسار؛ الفرق التي تهتم بالمظهر تحتاج إلى الاثنين.
س: لماذا يفشل اختبار لقطة شاشة جديد في أول تشغيل على CI؟
ج: في وضع التحديث الافتراضي يكتب Playwright الصورة المرجعية المفقودة لكنه يُفشل الاختبار، حتى لا ينجح تشغيل باعتماد مخرجاته بنفسه. ولّد الصورة المرجعية داخل صورة Docker المثبتة باستخدام --update-snapshots ثم احفظها في المستودع.
قراءات إضافية
الاختبارات
لماذا تنجح اختبارات Playwright محليًا وتفشل في CI: ملاحظاتنا الميدانية حول الانتظار التلقائي وعزل الاختبارات والتصحيح عبر التتبّع
كانت مجموعات الاختبارات الشاملة لدينا خضراء على أجهزة المطورين وحمراء بشكل متقطع على CI. هذه ملاحظاتنا الميدانية حول كون التذبذب في Playwright حالة تسابق داخل الاختبار وليس عشوائية في المتصفح، وكيف تحل التوكيدات web-first محل كل انتظار ثابت، وكيف تمنع الـ fixtures على مستوى العامل مشاركة البيانات، ولماذا يُعد trace: 'on-first-retry' أهم سطر في ملف الإعداد.
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.