بناء إطار عمل قوي لاختبارات E2E باستخدام أنماط التصميم
تُعد اختبارات E2E (End-to-End)، أو الاختبارات الشاملة، محاكاة لتجربة المستخدم الفعلية. هي لا تتعامل مع الدوال أو المتغيرات أو الفئات أو قواعد البيانات، بل تركز على التفاعلات الملموسة مثل الأزرار، النقرات، الرسائل المتوقعة، والروابط. يمكن القول إن اختبارات E2E هي “الاختبارات النهائية” لأنها تتحقق مما إذا كان المنتج ككل يتصرف كما هو متوقع.
بشكل عام، تُعد أتمتة اختبارات E2E مهمة صعبة. أولاً، تحتاج إلى أدوات يمكنها التفاعل مع التطبيق قيد الاختبار – مثل ملء النماذج، والانتظار حتى يتم تحميل الصفحة بالكامل، وما إلى ذلك. تحتاج أيضًا إلى استخلاص النتائج من واجهة المستخدم؛ فليس لديك دوال تُرجع كائنات، بل عناصر HTML تحتوي على المعلومات. محاكاة مستخدم حقيقي يمكن أن يكون تحديًا وقد يتطلب الكثير من الصيانة.
في هذا المقال، سأتحدث عن تجربتي الخاصة في بناء إطار عمل لاختبارات E2E. لقد طبقت بعض أنماط التصميم (Design Patterns) المبتكرة، لذا أعتقد أن هذا المحتوى سيكون مفيدًا لك حتى لو لم تكن تعمل مباشرة في أتمتة اختبارات E2E. هذا المنشور لا يعتمد على لغة برمجة أو أداة معينة (language and tool agnostic)؛ مما يعني أنني لن أشير إلى لغة برمجة محددة أو أداة E2E معينة مثل Selenium أو Puppeteer أو Playwright (وهي بالمناسبة أدوات رائعة لأتمتة اختبارات E2E). كما يركز هذا المنشور على اختبارات E2E لمواقع الويب.
التحدي التقني: المشكلة التي كان علي حلها
كان علي تصميم إطار عمل لإجراء اختبارات E2E مختلفة على مواقع ويب متعددة. وبشكل أكثر دقة، كنت بحاجة لإجراء بعض الاختبارات على مكونات React محددة داخل تلك المواقع. كان لكل مكون نفس البنية ومُحددات CSS بغض النظر عن الموقع، وتغيرت بشكل طفيف فقط من موقع لآخر. كنت بحاجة لإجراء اختبارات لكل عرض ممكن (viewport) مثل الجوال، الجهاز اللوحي، وسطح المكتب، وكان يجب أن تغير المكونات بنيتها عند تغيير العرض.
في هذا السيناريو، لم أكن أعرف شيئًا عن المطورين، لذا كان علي أن أكون مستعدًا لإدارة بعض التغييرات غير المتوقعة في الواجهة بسهولة نسبية. بعبارة أخرى، كان من الضروري أن يكون إطار العمل سهل الصيانة.
فكيف كان من المفترض أن أبني إطار عمل لاختبارات E2E لا يهتم كثيرًا إذا غير المطورون سمة id لزر معين تم النقر عليه في اختبار ما؟ كيف يمكنني كتابة اختبارات لمكون لم يتم إنشاؤه بعد؟ وكيف يمكنني جعل كل اختبار سهل القراءة والفهم؟
لقد تمكنت من تحقيق كل هذه الأهداف من خلال تطبيق بعض التجريدات (abstractions) وأنماط التصميم. دعنا نرى كيف فعلت ذلك.
نموذج كائن الصفحة (Page Object Model)
تجريد الصفحات لزيادة قابلية القراءة والصيانة
أول شيء نحتاج إلى القيام به هو إنشاء تجريد لصفحة الويب. هذا مهم لعدة أسباب. أولاً، سيزيد من قابلية القراءة. على سبيل المثال، لا تريد أن يكون لديك سطر في اختبارك يقول tool.getByCssSelector("button.btn.btn-submit").click(). بدلاً من ذلك، تريد سطرًا مثل page.clickSubmitLoginFormButton() أو ما شابه ذلك.
تحتاج أيضًا إلى الاحتفاظ بجميع مُحددات CSS والأشياء المتعلقة بنموذج كائن المستند (DOM-related stuff) في مكان واحد. وبهذه الطريقة، عندما يتغير شيء ما في الواجهة، تحتاج فقط إلى تعديل ملف واحد (أو ربما ملفين، ولكن ليس أكثر!).
يُطلق على هذا التجريد اسم نموذج كائن الصفحة (Page Object Model). تقوم بإنشاء فئة (class) تمثل فقط العناصر التي تهتم بها من الصفحة. تضع جميع الأشياء المتعلقة بـ DOM في هذه الفئات. في حالتي، فعلت ذلك بشكل مختلف قليلاً: لقد أنشأت فئتين لكل صفحة، وهما PageModel و Page Object.
في الفئة الأولى (PageModel)، وضعت عناصر الصفحة. على سبيل المثال، لنفترض أننا نختبر صفحة تسجيل دخول (login page)، فإن LoginPageModel الخاص بي سيكون كالتالي:
class LoginPageModel
constructor(tool)
this.tool = tool
loginUsernameInput()
return this.tool.getById('username-input')
loginPasswordInput()
return this.tool.getById('password-input)
loginSubmitButton()
return this.tool.getById('submit-login-button')
إذا تغير أي من هذه العناصر في المستقبل، فنحن نحتاج فقط إلى تعديل فئة PageModel المقابلة.
في فئة PageObject، أضفت الإجراءات التي يمكنك تنفيذها على الصفحة. مثال على فئة LoginPageObject سيكون:
class LoginPageObject
constructor(pageModel)
this.model = pageModel
typeUsername(username)
this.model.loginUsernameInput().type(username)
typePassword(password)
this.model.loginPasswordInput().type(password)
clickLoginSubmitButton()
this.model.loginSubmitButton().click()
فصل العناصر عن الإجراءات: مبدأ المسؤولية الواحدة
هنا يمكننا الاستفادة من لغة برمجة ذات أنواع ثابتة (statically typed language) يمكنها الحصول على جميع دوال الفئة النموذجية (model class) في وقت الترجمة (compilation time). وبهذه الطريقة، يمكن لأداة مثل IntelliSense أن تذكرنا باسم كل دالة تمثل عنصر صفحة. نحصل أيضًا على المزيد من أخطاء الترجمة وعدد أقل من أخطاء وقت التشغيل (runtime errors)، وهو أمر جيد جدًا لنا ولصحتنا العقلية.
لماذا نحتاج إلى فصل عناصر الصفحة عن إجراءات الصفحة؟ يمكن أن تكون الفئة الواحدة التي تحتوي على كل من العناصر والإجراءات كبيرة جدًا. يمكننا القول إننا من خلال القيام بذلك نطبق مبدأ المسؤولية الواحدة (Single Responsibility Principle)، وهذا أمر رائع. ولكن في هذه الحالة، ليس لذلك أهمية عملية كبيرة تتجاوز قابلية القراءة والحفاظ على الفئات بسيطة.
باستخدام تجريد Page Object، يمكننا إجراء اختبارات تعتمد فقط على كائنات الصفحة بدلاً من كتابة مُحددات CSS معقدة في منتصف كود الاختبار. نحتفظ بجميع الأشياء المتعلقة بـ DOM في مكان واحد، ويمكن أن تكون اختباراتنا أكثر تعبيرًا وسهولة في الفهم.
كتابة الاختبارات: نمط الواجهة (Facade Pattern)
تبسيط الواجهة للاختبارات المعقدة
لدينا الآن العديد من الفئات التي تحتوي على جميع عناصر وإجراءات العديد من الصفحات. ما نحتاج إلى القيام به الآن هو بناء اختباراتنا. ستوفر هذه الاختبارات واجهة بسيطة تعرض وظيفة run للعميل. تُرجع هذه الوظيفة نتيجة الاختبار. لا داعي للقلق بشأن الوصول إلى أي عنصر أو القيام بأي إجراء، بل يحتاج فقط إلى إنشاء مثيل (instantiate) للاختبار وتشغيله.
عندما نقدم واجهة بسيطة تخفي بنية تحتية أكثر تعقيدًا، فإننا نطبق نمط الواجهة (Facade Pattern). أعلم أن هذا مجرد اسم فاخر لشيء كان من الواضح أننا بحاجة إلى القيام به.
استمرارًا لمثال اختبار صفحة تسجيل الدخول لدينا، سيكون LoginTest شيئًا كهذا:
class LoginTest
constructor(loginPageObject)
this.pageObject = loginPageObject
run()
this.pageObject.typeUsername("TestUser")
this.pageObject.typePassword("TestPassword")
this.pageObject.clickLoginSubmitButton()
assert that the login was successful
السطر الأخير من دالة run هو تأكيد (assertion). اعتمادًا على تعقيد التأكيدات التي تستخدمها، يمكنك إما تعريفها بشكل منفصل أو داخل Page Object. باختيار الخيار الأول، يمكنك إعادة استخدام التأكيدات وتوسيعها. ولكن إذا كانت تأكيداتك محددة جدًا لكل حالة وبسيطة بما يكفي، فقد يكون الخيار الأول مبالغًا فيه، وربما يكون الخيار الثاني جيدًا لك.
حقن الاعتمادية (Dependency Injection)
نحن أيضًا نحقن اعتمادية Page Object في الاختبار. نحن لا نقوم بـ this.pageObject = new LoginPageObject() بل نستقبل الاعتمادية كوسيطة في الدالة البانية (constructor). وهذا ما يسمى حقن الاعتمادية (Dependency Injection). وبهذه الطريقة، يمكننا إنشاء نفس الاختبار لصفحة أخرى. نحن أيضًا نحقن Page Model في مثيلات Page Object. ثم، يمكن أن يكون لدينا نفس Page Object بنموذج آخر (مثال: نفس مثيل LoginPageObject مع LoginMobilePageModel بدلاً من LoginPageModel العادي).
ولكن الآن، لإنشاء مثيل لاختبار، نحتاج إلى إنشاء مثيل لواحد أو أكثر من Page Models، ثم واحد أو أكثر من Page Objects، وأخيرًا الاختبار نفسه. يبدو هذا كثيرًا من العمل. وهذا بالتحديد أحد عيوب استخدام حقن الاعتمادية – ولكن المشكلة قابلة للحل!
نمط المصنع (Factory Pattern)
تبسيط إنشاء الكائنات المعقدة
دعنا نُفوّض المسؤولية إلى تجريد آخر. في هذه الحالة، سنقوم بإنشاء بعض المصانع (factories). المصانع هي فئات تُستخدم لإنشاء مثيلات لفئات أخرى. ستكون كل فئة مصنع مسؤولة عن إنشاء مثيل لاختبار معين. هذا هو نمط المصنع (Factory Pattern) قيد العمل.
لذلك يمكننا إنشاء LoginTestFactory لاختبار LoginTest الخاص بنا:
import tool
class LoginTestFactory
create(config)
if config.viewport == 'mobile' then
return new LoginTest(new LoginPageObject(new LoginMobilePageModel(tool)))
else
return new LoginTest(new LoginPageObject(new LoginPageModel(tool)))
هنا، بـ tool، نمثل أي تقنية ممكنة يمكنك استخدامها للحصول على عناصر الصفحة والتفاعل معها. ربما لا تمرر الأداة المستوردة كما هي، ولكنك تنشئ بعض الكائنات باستخدام تلك الأداة ثم تمرر تلك الكائنات كمعاملات. ولكن الفكرة هي أن كل المنطق المعقد نسبيًا لإنشاء مثيل لاختبار يتم تغليفه في كائن مصنع (factory object).
لتشغيل اختبارنا، نحتاج فقط إلى القيام بشيء كهذا:
runLoginTestDesktop()
factory = new LoginTestFactory()
config = new ConfigObject(viewport = 'desktop')
test = factory.create(config)
test.run()
runLoginTestMobile()
factory = new LoginTestFactory()
config = new ConfigObject(viewport = 'mobile')
test = factory.create(config)
test.run()
الآن، في قسم الخلاصة، سنتحقق مما إذا كنا قد حققنا أهدافنا الأولية.
الخلاصة
إن بناء إطار عمل للاختبارات بهذه الطريقة يمكن أن يقلل بشكل كبير من تكلفة التغييرات في واجهة المستخدم. يتم عزل جميع الأكواد التي تعتمد على واجهة المستخدم في فئات محددة تجرد مفهوم الصفحة. يسمح لك هذا التجريد أيضًا بكتابة اختباراتك للأسبوع القادم (أعني الاختبارات للمكونات التي لم يتم إنشاؤها بعد).
ما عليك سوى إنشاء PageModels و PageObjects الجديدة المطلوبة لمحاكاة العناصر الموجودة في الصفحة التي سيتم إنشاؤها، ويمكنك بناء بقية العملية بنفس الطريقة التي رأيناها حتى الآن. عندما تكون لديك عناصر محددة في الواجهة، يمكنك تغيير نماذج الصفحات والتحقق مما إذا كان التطبيق يتصرف كما هو متوقع.
لديك أيضًا اختبارات سهلة القراءة والفهم للغاية لأنك تقوم بإجراءات معبرة مثل this.pageObject.clickLoginSubmitButton(). وبالتالي، يمكن لاختباراتك وصف متطلبات تطبيقك ويمكن صيانتها بسهولة.
إن أتمتة اختبارات E2E صعبة لأنه من الصعب إبقاؤها بسيطة. والاختبار المعقد ليس اختبارًا. في هذا المنشور، عرضت بعض أنماط التصميم والممارسات الجيدة التي يمكنك استخدامها لجعل العملية أكثر سلاسة. لقد حاولت أن أجعله لا يعتمد على لغة أو أداة معينة حتى تتمكن من تطبيق هذه الممارسات في مشروعك بغض النظر عن اللغة أو التقنية التي تستخدمها. لقد افترضت فقط لغة برمجة موجهة للكائنات (Object-Oriented programming language).
سواء كنت تقوم ببناء إطار عمل لاختبارات E2E أم لا، أعتقد أن هذا المقال لا يزال يمكن أن يكون ذا فائدة لك. يمكن تطبيق بعض هذه الحيل في مجموعة واسعة نسبيًا من المشاكل.
الخلاصة التقنية
يُبرهن هذا المقال ببراعة كيف يمكن لأنماط التصميم الكلاسيكية أن تُحدث ثورة في قابلية صيانة ومرونة أطر عمل اختبارات E2E. من خلال تطبيق نموذج كائن الصفحة (Page Object Model)، لا يتم فقط تحسين قابلية القراءة بشكل كبير، بل يتم أيضًا عزل التغييرات في واجهة المستخدم، مما يقلل من “تكلفة التغيير” بشكل كبير. يضمن نمط الواجهة (Facade Pattern) أن تكون الاختبارات نفسها نظيفة ومفهومة، بينما يحل نمط المصنع (Factory Pattern) بذكاء تعقيدات إنشاء الكائنات، خاصةً في سيناريوهات مثل اختبارات الاستجابة لأحجام الشاشات المختلفة. إن النهج الموضح هنا، والذي يركز على التجريد وفصل الاهتمامات، هو حجر الزاوية في هندسة البرمجيات القوية، مما يجعله ذا قيمة تتجاوز نطاق اختبارات E2E إلى أي نظام يتطلب قابلية للتوسع والصيانة على المدى الطويل.