نموذج كائنات الصفحات

نموذج كائنات الصفحات (Page Object Model)، ويُختصر POM، نمط تصميم لأتمتة اختبارات الواجهة. تحصل كل صفحة أو شاشة على صنف (class) يُسمّى كائن الصفحة (Page Object)، يعرض ما يستطيع المستخدم فعله فيها في صورة دوال، مثل search(term) أو placeOrder(address)، ويُخفي طريقة تنفيذه: المحدِّدات (Locators) والنقرات والكتابة والانتظار. تستدعي الاختبارات هذه الدوال وتحتفظ بالتأكيدات لنفسها. فإذا تغيّرت تسمية زر «Place order» تغيّر سطر واحد في CheckoutPage بدلاً من كل اختبار يقدّم طلباً.

ورغم الاسم، يلاحظ Martin Fowler أن كائنات الصفحات تُبنى عادةً للأجزاء المهمة من الصفحة لا لكل صفحة كاملة؛ وكثيراً ما تُسمّى أصناف هذه الأجزاء كائنات مكوّنات (Component Objects). أما نمط Screenplay فطريقة بديلة لتنظيم الشيفرة نفسها.

لماذا يهم

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

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

ما يدخل في كائن الصفحة وما يبقى خارجه

يغلّف كائن الصفحة صفحةً أو جزءاً منها بواجهة برمجية (API) بمصطلحات التطبيق. يُنشأ لكل اختبار من مقبض المتصفح الخاص به، مثل التجهيزة (Fixture) page في Playwright من الدرس السابق، وهو الشيفرة الوحيدة التي تعرف كيف بُنيت شاشته. ويضع توثيق Selenium قواعد تصلح لأي أداة:

  • الدوال العامة هي الخدمات التي تقدمها الصفحة، وتُسمّى بحسب ما يريده المستخدم: placeOrder() لا clickBlueButton().
  • التفاصيل الداخلية تبقى في الداخل: المحددات والإطارات (frames) وترتيب النقرات والانتظار. ولا يحتاج الاختبار إلى أي محدِّد.
  • لا تأكيدات: تُعيد الدالة بيانات أو عنصراً، والاختبار هو من يقرر ما الصحيح. ويعرض Fowler الرأيين ثم يفضّل هو أيضاً إبقاء التأكيدات خارجها؛ أما الفحوص التي تتكرر في اختبارات كثيرة فمكانها دوال تأكيد مساعدة مشتركة. والفحص الوحيد الذي مكانه الداخل هو أن الصفحة الصحيحة قد حُمّلت، ويُجرى عند إنشاء الكائن. ولأن الباني (constructor) في JavaScript لا يستطيع استخدام await، تُجري كائنات الصفحات في Playwright هذا الفحص في الدالة التي تصل إلى الصفحة، كما تفعل placeOrder() أدناه.
  • التنقل يُعيد كائن الصفحة التالية: cart.checkout() تُعيد CheckoutPage، فتوثّق أنواع القيم المُعادة مسار الرحلة.
  • النتائج المختلفة للإجراء نفسه دوال مختلفة: signIn() تُعيد صفحة الحساب، وsignInExpectingError() صفحة تسجيل الدخول.

كائنات المكوّنات: صنف واحد لكل جزء متكرر

كثير من أجزاء الشاشة ليست صفحات: ترويسة فيها البحث وعدد عناصر السلة، وصف في جدول الطلبات، ومنتقي تاريخ، ونافذة حوارية. يسمّي توثيق Selenium أصنافها كائنات مكوّنات الصفحة (Page Component Objects)، ومثال Fowler قائمة ألبومات تحتوي كائنات ألبومات. وتتركّب كائنات الصفحات منها: لدى CheckoutPage ترويسة header، وOrdersPage تُعيد OrderRow لرقم طلب معيّن.

والتقنية الأساسية هي الجذر (root): يتلقى كائن المكوّن محدِّد الحاوية الخاصة به ولا يبحث إلا داخلها، فيخدم صنف واحد كل الصفوف، ولا يمكن أن تنقر order('ORD-2026-000184').cancel() زر الصف المجاور. يضيّق Playwright المحدِّد بـ filter({ hasText }) ويبحث داخله بـ getByRole، وفي Selenium تُستدعى findElement على العنصر الأب.

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

الاستدعاءات تنزل عبر الطبقات؛ وقد يستدعي كائن الصفحة المكتبة مباشرةً

  1. 1الاختباراتtests/السيناريو بلغة المستخدم وكل التأكيدات: checkout.placeOrder('Home') ثم expect(…)

    تتغير حين يتغير المتطلب

  2. 2كائنات الصفحاتpages/صنف لكل صفحة: دوال تُسمّى بما يفعله المستخدم وتُعيد بيانات أو محدِّداً أو كائن الصفحة التالية؛ بلا تأكيدات

    تتغير حين تتغير صفحة أو مسار: ملف واحد لا كل الاختبارات

  3. 3كائنات المكوّناتcomponents/الترويسة وصف الطلب ومنتقي التاريخ: محددات نسبية إلى جذر المكوّن نفسه

    تتغير حين يتغير جزء مشترك، لكل الصفحات التي تعرضه

  4. 4مكتبة أتمتة المتصفحPlaywright · Selenium WebDriver · Cypressتعثر على العناصر وتنقر وتكتب وتنتظر التطبيق

    تتغير حين تحدّث الأداة أو تستبدلها

من الاختبارات إلى مكتبة أتمتة المتصفح:غير مسموح: محدِّد أو نقرة أو انتظار مكتوب داخل اختبار

// components/order-row.ts: one class for every row of the orders table
import type { Locator } from '@playwright/test';

export class OrderRow {
  constructor(private readonly root: Locator) {}    // the row's own element

  status(): Locator {
    return this.root.getByTestId('order-status');
  }

  async cancel(): Promise<void> {
    await this.root.getByRole('button', { name: 'Cancel order' }).click();
  }
}

// pages/orders-page.ts: the page composes the component
import type { Page } from '@playwright/test';
import { OrderRow } from '../components/order-row';

export class OrdersPage {
  constructor(private readonly page: Page) {}

  order(orderNumber: string): OrderRow {
    const row = this.page.getByRole('row').filter({ hasText: orderNumber });
    return new OrderRow(row);
  }
}

// in a test
const ordersPage = new OrdersPage(page);
const row = ordersPage.order('ORD-2026-000184');
await row.cancel();
await expect(row.status()).toHaveText('Cancelled');

جرّبها: منسّق HTML

مثال تطبيقي: إعادة هيكلة اختبار إتمام الشراء

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

بعد إعادة الهيكلة (Refactoring) تحمل كائنات الصفحات الخطوات، ويحمل مكوّن Header عدد عناصر السلة، ولا يذكر الاختبار إلا السيناريو والنتيجة المتوقعة. ويمثّل CheckoutPage نتيجتَي الإرسال بدالتين تتشاركان دالة خاصة هي submit().

  • تغيّرت تسمية «Place order» إلى «Pay now»: قبل إعادة الهيكلة يلزم تعديل 12 اختباراً، وبعدها سطر واحد في CheckoutPage.
  • تغيّر معرّف الاختبار (test id) لعدد عناصر السلة أو انتقل العدد إلى موضع آخر داخل الترويسة: تتغير Header.cartCount() وحدها، ولا يتغير أي اختبار.
// BEFORE: tests/checkout.spec.ts (these steps are copied into 12 tests)
test('customer pays with a saved address', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByRole('radio', { name: 'Home' }).check();
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByRole('heading', { level: 1 }))
    .toHaveText('Thank you for your order');
  await expect(page.getByRole('banner').getByTestId('cart-count')).toHaveText('0');
});

// AFTER: pages/checkout-page.ts (CartPage, ConfirmationPage and the
// Header component are written the same way; imports omitted)
export class CheckoutPage {
  readonly header: Header;

  constructor(private readonly page: Page) {
    this.header = new Header(page);
  }

  async placeOrder(address: string): Promise<ConfirmationPage> {
    await this.submit(address);
    await this.page.waitForURL('**/order/confirmation');   // the page-loaded check
    return new ConfirmationPage(this.page);
  }

  async placeOrderExpectingError(address: string): Promise<CheckoutPage> {
    await this.submit(address);
    return this;                      // stays on checkout, which shows the error
  }

  errorMessage(): Locator {
    return this.page.getByRole('alert');
  }

  private async submit(address: string): Promise<void> {
    await this.page.getByRole('radio', { name: address }).check();
    await this.page.getByRole('button', { name: 'Place order' }).click();
  }
}

// AFTER: tests/checkout.spec.ts
test('customer pays with a saved address', async ({ page }) => {
  const cart = await new CartPage(page).open();
  const checkout = await cart.checkout();
  const confirmation = await checkout.placeOrder('Home');
  await expect(confirmation.heading()).toHaveText('Thank you for your order');
  await expect(confirmation.header.cartCount()).toHaveText('0');
});

جرّبها: مقارنة النصوص (Diff) جرّبها: منسّق JavaScript

نمط Screenplay: الممثلون والمهام والأسئلة

تنظّم كائنات الصفحات شيفرة الاختبار حول الشاشات، أما نمط Screenplay فينظّمها حول الأشخاص الذين يستخدمون النظام وأهدافهم. ومن تطبيقاته Serenity BDD للغة Java وSerenity/JS، وفيما يلي مفاهيمه بمصطلحات Serenity/JS.

ولأن للممثل أكثر من قدرة، يستطيع سيناريو واحد أن يُنشئ طلباً عبر API ثم يتحقق منه في المتصفح.

  • الممثلون (Actors): أشخاص أو أنظمة خارجية تتعامل مع النظام قيد الاختبار، مثل ليلى، وهي عميلة عائدة.
  • القدرات (Abilities): أغلفة رقيقة حول المكتبات التي يستخدمها الممثل، مثل Playwright للمتصفح أو عميل HTTP لواجهة REST API.
  • التفاعلات (Interactions): أنشطة منخفضة المستوى عبر واجهة واحدة، مثل النقر على زر أو إرسال طلب.
  • المهام (Tasks): تسلسلات من الأنشطة تشكّل خطوة عمل، مثل PlaceOrder المبنية من فتح السلة واختيار العنوان والإرسال.
  • الأسئلة (Questions): تسترجع معلومات، مثل حالة الطلب المعروضة في الصفحة أو الواردة في استجابة API؛ ويتحقق الاختبار من الإجابة.

أنماط خاطئة تحوّل كائنات الصفحات إلى عبء

كل نمط منها يجعل تعديل الحزمة أصعب.

  • التأكيدات داخل كائنات الصفحات، مثل verifyOrderTotal(): لم يعد الاختبار يُظهر ما يتوقعه، ولا تستطيع اختبارات تتوقع أشياء مختلفة أن تتشارك الدالة.
  • الصفحة العملاقة (God Page): صنف BasePage من مئات الأسطر ترثه كل الصفحات، أو صنف واحد لتطبيق كامل من صفحة واحدة. فضّل الأصناف الصغيرة والتركيب (للصفحة ترويسة) على الوراثة العميقة.
  • كشف الخطوات: اختبارات تنقر عناصر كائن الصفحة وتملؤها واحداً تلو الآخر، فيعود المسار إلى الاختبار. أما إعادة محدِّد من أجل تأكيد يعيد المحاولة، كما تفعل أمثلة Playwright، فلا بأس بها.
  • انتظار ثابت في الدوال: waitForTimeout(3000) داخل placeOrder() يبطئ كل اختبار يستدعيها، ويفشل مع ذلك في يوم بطيء.
  • دوال تقودها الأعلام (flags): checkout(true, false, 'Home') تُخفي عدة مسارات خلف اسم واحد.
  • الحالة المشتركة: مشغّل ثابت (static driver) أو كائن صفحة واحد للحزمة كلها يكسر التشغيل المتوازي؛ أنشئ كائنات الصفحات لكل اختبار أو عبر تجهيزة.

أخطاء شائعة

  • كتابة كائنات صفحات لكل الشاشات مقدّماً بدلاً من إضافة الأصناف والدوال حين تحتاجها الاختبارات.
  • تسمية الدوال بحسب الآلية، مثل clickSubmitButton()، بدلاً مما يحققه المستخدم.
  • توقّع أن يُصلح النموذج الاختبارات الهشة: فهو ينقل XPath الموضعي أو الانتظار الثابت إلى مكان واحد لكنه لا يحسّنه.
  • إنشاء بيانات الاختبار بالنقر عبر كائنات الصفحات، مثل تسجيل عميل جديد في كل اختبار، بينما استدعاء API داخل تجهيزة أسرع.

من منظور ضمان الجودة

كائنات الصفحات شيفرة مشتركة: راجعها مع المطورين، وتوقّع من طلب الدمج (Pull Request) الذي يغيّر الترميز أن يحدّث كائن الصفحة أو المكوّن أيضاً. واتفق مع الفريق مبكراً على الأعراف ودوّنها في المستودع: أسماء الدوال بلغة المستخدم، وأنواع القيم المُعادة، وغياب التأكيدات، والمحددات المفضّلة، واطلب من المطورين أسماء ميسَّرة (accessible names) أو معرّفات اختبار حيث تفتقر إليها الصفحة.

عندما يفشل اختبار واجهة، فإن السطر الفاشل في تتبّع المكدّس (stack trace)، داخل دالة مثل CheckoutPage.placeOrder، يبيّن أين انكسرت الرحلة؛ فاذكره في تقرير الخطأ. وإذا ظل تغيير صغير في الواجهة يفرض تعديل ملفات كثيرة، فقد تسرّبت البنية: اطرح ذلك في الاجتماع الاستعادي (Retrospective) بدلاً من الترقيع اختباراً تلو الآخر.

تمرين عملي

أعِد هيكلة اختبار بحث وسلة إلى كائنات صفحات ومكوّنات

المطلوب

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

بعد ذلك اذكر الملفات التي تتغير إذا صار «Add to cart» «Add to basket»، وإذا خرج عدد عناصر السلة من الترويسة.

المادة
test('adds a found product to the cart', async ({ page }) => {
  await page.goto('/');
  const header = page.getByRole('banner');
  await header.getByRole('searchbox', { name: 'Search products' }).fill('kettle');
  await header.getByRole('button', { name: 'Search' }).click();
  const card = page.getByTestId('product-card').filter({ hasText: 'Steel Kettle' });
  await expect(card.getByTestId('price')).toHaveText('89.00 SAR');
  await card.getByRole('button', { name: 'Add to cart' }).click();
  await expect(header.getByTestId('cart-count')).toHaveText('1');
  await header.getByRole('link', { name: 'Cart' }).click();
  const line = page.getByTestId('cart-line').filter({ hasText: 'Steel Kettle' });
  await expect(line).toContainText('89.00 SAR');
});
الخطوات
  1. علّم كل محدِّد والصفحة أو الجزء المتكرر الذي ينتمي إليه.
  2. حدّد الأصناف: كائن صفحة لكل صفحة، وكائن مكوّن لكل جزء متكرر.
  3. اكتب دوال كل صنف بحسب نية المستخدم، مع نوع القيمة المُعادة: بيانات، أو محدِّد للتأكيد عليه، أو كائن الصفحة التالية.
  4. أعِد كتابة الاختبار بهذه الدوال مع إبقاء كل تأكيد فيه، وأجب عن سؤالَي التغيير.
إظهار الإجابة النموذجية
الإجابة النموذجية

الأصناف: مكوّن Header (جذره: الترويسة banner): search(term) ← SearchResultsPage، وcartCount() ← محدِّد، وopenCart() ← CartPage. وHomePage: open() ← HomePage، وheader. وSearchResultsPage: header وproduct(name) ← ProductCard. ومكوّن ProductCard (جذره: البطاقة المصفّاة بالاسم): price() ← محدِّد، وaddToCart() التي لا تُعيد شيئاً لأن الصفحة لا تتغير. وCartPage: line(name) ← محدِّد.

يصبح الاختبار سلسلة من النوايا: const home = await new HomePage(page).open();، ثم const results = await home.header.search('kettle');، ثم const kettle = results.product('Steel Kettle');، ثم await expect(kettle.price()).toHaveText('89.00 SAR');، ثم await kettle.addToCart();، ثم await expect(results.header.cartCount()).toHaveText('1');، ثم const cart = await results.header.openCart();، وأخيراً await expect(cart.line('Steel Kettle')).toContainText('89.00 SAR');. وتبقى التأكيدات الثلاثة وقيمها المتوقعة في الاختبار، ولا يبقى فيه أي محدِّد.

إعادة تسمية الزر تغيّر محدِّداً واحداً في ProductCard.addToCart() ولا تغيّر أي اختبار. أما العدد الذي يخرج من الترويسة فلم يعد مكانه Header: انقل cartCount() إلى المكوّن الذي صار يحتويه، مثل مكوّن جديد SideMenu، وعدّل هذا الاستدعاء في الاختبارات التي تقرأ العدد؛ ويبقى المحدِّد في ملف واحد.

أسئلة شائعة

هل نموذج كائنات الصفحات خاص بـ Selenium؟

لا. يلاحظ Fowler أنه ينطبق على أي تقنية واجهة، وتستخدمه الفرق مع Playwright وCypress وأدوات الجوال مثل Appium واختبارات واجهات سطح المكتب. وتستخدم بعض الفرق بدلاً منه دوال مساعدة بسيطة أو الأوامر المخصصة لإطار العمل؛ والمهم أن تعبّر الاختبارات عن النية وأن يعرف مكان واحد بنية كل شاشة.

هل يُعيد كائن الصفحة محددات أم قيماً؟

يعتمد ذلك على الأداة. مع التأكيدات التي تعيد المحاولة، مثل expect(locator).toHaveText() في Playwright، أعِد محدِّداً حتى ينتظر التأكيد الصفحة؛ أما القيمة المقروءة مرة واحدة، مثل await locator.textContent()، فتتسابق مع الصفحة. ومع Selenium تُعيد الدوال عادةً قيماً بعد انتظار صريح. وفي الحالتين يتولى الاختبار التحقق.

هل أستخدم Screenplay بدلاً من كائنات الصفحات؟

ابدأ بكائنات الصفحات والمكوّنات: فهي أبسط وتكفي معظم الحزم. ويؤتي Screenplay ثماره في الحزم الكبيرة ذات أدوار المستخدمين الكثيرة، أو حين تجمع السيناريوهات بين واجهات، مثل API للتهيئة والمتصفح للتحقق، وثمنه أصناف أكثر وأصغر وبداية أصعب على الفريق. وكلاهما يفصل النية عن التفاعل، لذا فالانتقال لاحقاً ممكن.

المراجع

  • Page object models — SeleniumSelenium Projectنمط كائن الصفحة (Page Object): صنف لكل صفحة أو مكوّن يخفي المحدِّدات ويعرض الإجراءات التي يحتاجها الاختبار، فيقل التكرار.
  • Page Object (Martin Fowler)martinfowler.com · 2013نمط كائن الصفحة (Page Object): تغليف الصفحة بواجهة برمجية خاصة بالتطبيق كي لا تتعامل الاختبارات مع HTML مباشرة، مع إبقاء التأكيدات في الاختبارات لا في كائن الصفحة.
  • Locators — PlaywrightMicrosoft (Playwright)كيف يعثر Playwright على العناصر، ولماذا تُعاد تحديد المحدِّدات وتنتظر تلقائياً، ولماذا يُوصى بالمحدِّدات المبنية على الدور أولاً.
  • Best Practices — PlaywrightMicrosoft (Playwright)اختبر السلوك الذي يراه المستخدم، واجعل الاختبارات معزولة، وفضّل المحدِّدات المتينة والتأكيدات التي تنتظر، واستخدم التتبّع لتصحيح الأخطاء.

تدرّب بأدوات متقن

تجميل كود JavaScript وTypeScript بمسافات بادئة وتباعد متسقين.

التنسيق

تجميل HTML بمسافات بادئة صحيحة للعناصر المتداخلة، مع الحفاظ على كتل pre وscript وstyle.

التنسيق

للتعمّق أكثر

تقدّمك في هذا الموضوع

جزء من مسار تعلم ضمان الجودة