یک تغییر کوچک در Header باعث شکست ۴۲ تست UI شده است. آیا Page Object Model مشکل را حل می‌کند؟ اگر Locator همان Header در ۴۲ تست کپی شده باشد، احتمالاً بله. اما اگر شکست از داده مشترک، انتظار زمانی یا وابستگی بین تست‌ها باشد، POM فقط خرابی را پشت یک کلاس پنهان می‌کند.

Page Object Model یا POM الگویی برای جداکردن دانش UI از قصد تست است؛ نه درمان خودکار Flaky test و نه الزام هر پروژه. در این راهنما، معماری درست، مثال TypeScript/Playwright، سیاست Locator، Component Object، Wait، Assertion، تفاوت Selenium/Cypress/Playwright و مسیر مهاجرت را عملی بررسی می‌کنیم.

آخرین بازبینی مستندات فریم‌ورک‌ها: ۱۵ مرداد ۱۴۰۵ / ۶ اوت ۲۰۲۶.

قاعده تصمیم: POM را وقتی به کار ببرید که چند تست واقعاً از یک سطح تعامل UI مشترک سود می‌برند. متدهای عمومی باید «خدمت صفحه» را بیان کنند؛ Locator، Wait و جزئیات DOM در یک مرز کوچک بمانند؛ Oracle و Assertion کسب‌وکار در تست حفظ شوند.

Page Object Model چیست؟

POM یک Design pattern در اتوماسیون UI است. یک Page Object رابطی برنامه‌نویسی برای خدمات یک صفحه یا ناحیه از محصول می‌سازد. تست به‌جای تکرار find/click/fill و Selector، متدی مانند addProductToCart() یا submitLogin() را صدا می‌زند.

مستند رسمی Selenium درباره Page Object Model دو مزیت اصلی را برجسته می‌کند: جداسازی کد تست از دانش صفحه و داشتن یک محل برای عملیات/خدمات UI. همان مستند Page Component Object را نیز برای بخش‌های تکرارشونده معرفی می‌کند.

POM چه چیزی نیست؟

  • کپی ساختار HTML در یک کلاس نیست؛
  • یک BasePage با ده‌ها Helper عمومی نیست؛
  • محل نگهداری Test data، Secret و Rule همه محصولات نیست؛
  • مجوز اجرای همه تست‌ها از UI نیست؛
  • تضمین کاهش Flaky یا هزینه نگهداری نیست؛
  • الگوی اجباری Cypress، Playwright یا هر Framework دیگری نیست.

قبل از انتخاب الگو، لایه و اقتصاد Automation را در راهنمای اتوماسیون تست مشخص کنید.

مشکل قبل از POM؛ کد تکراری و قصد پنهان

await page.locator('#email').fill(user.email);
await page.locator('#password').fill(user.password);
await page.locator('button:nth-child(3)').click();
await expect(page.locator('.dashboard h1')).toContainText('سلام');

در این تست، Selector، تعامل، داده و Assertion کنار هم‌اند. اگر فرم ورود در چند تست تکرار شود، تغییر UI به تغییر چند فایل منجر می‌شود. Selector سوم نیز قصد کاربر را نشان نمی‌دهد.

POM دانش «چگونه با صفحه تعامل کنیم» را متمرکز می‌کند، اما Test همچنان می‌گوید چه رفتار و Oracleای زیر آزمون است.

معماری پیشنهادی Suite UI

لایه مسئولیت نباید مالک باشد
Tests / Specs سناریو، Risk، Oracle و Assertion جزئیات DOM و Waitهای تکراری
Flows / Tasks Journey چندصفحه‌ای و قصد کسب‌وکار Assertion همه Testها یا State جهانی
Pages / Components Locator، تعامل و آمادگی UI Test data واقعی و تصمیم انتشار
Fixtures / Data builders Setup، Cleanup، حساب و داده مستقل رفتار صفحه
Framework/Infra Browser، Config، Artifact، Retry policy Rule کسب‌وکار

همه پروژه‌ها به هر پنج لایه نیاز ندارند. برای Suite کوچک، یک Component و چند Helper روشن بهتر از معماری سنگین است. لایه زمانی ایجاد شود که مرز مسئولیت یا تکرار واقعی وجود دارد.

مثال POM با Playwright و TypeScript

Page Object ورود

import { type Locator, type Page } from '@playwright/test';

export type Credentials = {
  email: string;
  password: string;
};

export class LoginPage {
  readonly heading: Locator;
  readonly email: Locator;
  readonly password: Locator;
  readonly submitButton: Locator;
  readonly errorAlert: Locator;

  constructor(private readonly page: Page) {
    this.heading = page.getByRole('heading', { name: 'ورود' });
    this.email = page.getByLabel('ایمیل');
    this.password = page.getByLabel('رمز عبور');
    this.submitButton = page.getByRole('button', { name: 'ورود' });
    this.errorAlert = page.getByRole('alert');
  }

  async open(): Promise<void> {
    await this.page.goto('/login');
    await this.heading.waitFor();
  }

  async submit(credentials: Credentials): Promise<void> {
    await this.email.fill(credentials.email);
    await this.password.fill(credentials.password);
    await this.submitButton.click();
  }
}

این کلاس Locator و تعامل را متمرکز می‌کند. open() یک Guard آماده‌بودن صفحه دارد، اما درباره صحیح‌بودن Login تصمیم نمی‌گیرد.

تست موفق و ناموفق

import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';

test('کاربر معتبر وارد حساب می‌شود', async ({ page }) => {
  const login = new LoginPage(page);

  await login.open();
  await login.submit({
    email: 'qa-user@example.test',
    password: process.env.TEST_USER_PASSWORD!
  });

  await expect(page).toHaveURL(/\/account$/);
  await expect(page.getByRole('heading', { name: 'حساب من' }))
    .toBeVisible();
});

test('پیام ورود نامعتبر وجود حساب را افشا نمی‌کند', async ({ page }) => {
  const login = new LoginPage(page);

  await login.open();
  await login.submit({
    email: 'unknown@example.test',
    password: 'invalid-value'
  });

  await expect(login.errorAlert)
    .toHaveText('اطلاعات ورود معتبر نیست');
  await expect(page).toHaveURL(/\/login$/);
});

Oracle در Spec خوانا است. Secret از Environment می‌آید و مقدار واقعی وارد Repository نمی‌شود. برای یک پروژه واقعی، حساب تست باید با Fixture یا API مجاز ساخته و Cleanup شود.

چرا submit مقصد را برنمی‌گرداند؟

یک Submit می‌تواند Dashboard، MFA، تغییر رمز یا خطا بسازد. متد عمومی submit() اجازه می‌دهد Test مقصد را بر اساس Scenario ارزیابی کند. گزینه دیگر، متدهای صریح است:

  • loginAsValidUser() که DashboardPage برمی‌گرداند؛
  • loginExpectingError() که همان LoginPage را برمی‌گرداند.

Selenium نیز این تفکیک نتیجه را در مستند POM خود نشان می‌دهد. یک متد مبهم که همیشه مقصد موفق را فرض می‌کند، برای مسیر منفی طراحی مناسبی نیست.

Assertion داخل Page Object؛ قانون یا Guideline؟

به‌طور پیش‌فرض، Assertion مربوط به رفتار تحت تست در Spec بماند. دلیل:

  • Oracle در کنار نام سناریو دیده می‌شود؛
  • Page Object برای نتیجه‌های متفاوت قابل‌استفاده می‌ماند؛
  • Fail report نشان می‌دهد چه رفتار کسب‌وکاری شکست خورده است.

استثنای پذیرفتنی، Guard یا Invariant خود Page است؛ مثلاً پس از ساخت CheckoutPage، Heading یا Route لازم آماده باشد. حتی این Guard نباید Failure اصلی را مبهم کند.

Getter یا Locator عمومی؟

در Selenium کلاسیک معمول است Page متدی مانند getErrorText() ارائه کند. در Playwright، مستند رسمی Page Object Models نمونه‌هایی با Locatorهای عمومی نیز دارد تا Assertionهای Auto-retrying در Test استفاده شوند. یک سبک را تیمی انتخاب کنید:

  • اگر Locator عمومی است، نام Semantic و دامنه محدود داشته باشد؛
  • Raw driver یا Selector string را بی‌دلیل افشا نکنید؛
  • تست نباید دوباره DOM داخلی Component را بشکافد.

Locator پایدار؛ قرارداد تست با UI

اولویت گزینه مزیت احتیاط
۱ Role + accessible name / Label نزدیک به تجربه کاربر و A11y متن محلی‌شده می‌تواند تغییر کند
۲ شناسه اختصاصی تست مانند data-testid قرارداد پایدار و مستقل از CSS نباید جای Semantic HTML را بگیرد
۳ ID/Attribute پایدار دامنه ساده و سریع ID تولیدی یا وابسته به داده نامناسب است
۴ CSS محدود به Component انعطاف مناسب وابستگی به Layout شکننده می‌شود
آخر XPath/CSS طولانی و موقعیتی گاهی تنها راه Legacy تغییر کوچک DOM آن را می‌شکند

این ترتیب قانون جهانی نیست. اگر متن فارسی بخشی از رفتار مورد آزمون است، Locator متنی ارزشمند است؛ اگر تیم ترجمه متن را مرتب تغییر می‌دهد و هدف تست چیز دیگری است، قرارداد data-testid پایدارتر است. متن، RTL و دسترس‌پذیری را در تست‌های جدا فراموش نکنید.

قرارداد Locator با توسعه

  • Attribute تست بخشی از Contract و Code review باشد؛
  • نام بر اساس قصد، مانند checkout-submit، نه رنگ/موقعیت؛
  • برای فهرست، شناسه دامنه پایدار استفاده شود؛
  • حذف یا تغییر Contract در Pull Request قابل‌مشاهده باشد؛
  • Self-healing تغییر اشتباه را بی‌صدا پنهان نکند.

Wait و همگام‌سازی در POM

POM Flaky را خودکار حل نمی‌کند. قرار دادن sleep(3000) در BasePage فقط تأخیر و ناپایداری را متمرکز می‌کند.

قاعده عملی

  • از Auto-wait و Assertionهای Retry شونده Framework استفاده کنید؛
  • برای یک State مشاهده‌پذیر صبر کنید، نه زمان دلخواه؛
  • Navigation، Response یا UI state را بر اساس رفتار واقعی انتخاب کنید؛
  • Timeout محلی باید دلیل مشخص داشته باشد؛
  • Implicit و Explicit wait ناسازگار را بی‌برنامه ترکیب نکنید؛
  • Fail باید Artifact کافی مانند Trace/Screenshot/Log داشته باشد.

در Selenium، Explicit wait برای Condition مرتبط رایج است؛ در Playwright Locatorها و Actionها Auto-wait دارند، بنابراین Wait دستی اضافی گاهی ضدالگوست. در هر دو، Ready بودن UI را با «قابل‌اقدام بودن» بسنجید، نه فقط وجود عنصر در DOM.

Page Component Object؛ Composition به‌جای God Page

صفحه فروشگاه از Header، Product card، Cart drawer و Toast تشکیل شده است. اگر همه داخل ProductsPage باشند، کلاس به‌سرعت بزرگ می‌شود.

export class ProductCard {
  constructor(private readonly root: Locator) {}

  async name(): Promise<string> {
    return (await this.root.getByTestId('product-name').innerText()).trim();
  }

  async addToCart(): Promise<void> {
    await this.root.getByRole('button', { name: 'افزودن به سبد' }).click();
  }
}

Page می‌تواند مجموعه‌ای از ProductCardها برگرداند یا محصول را بر اساس شناسه دامنه پیدا کند. Composition مرز DOM را نزدیک Component نگه می‌دارد و Header مشترک را بدون ارث‌بری عمیق استفاده می‌کند.

BasePage چه زمانی مفید است؟

برای وابستگی کوچک و واقعاً مشترک مانند Page، Navigation پایه یا ثبت Artifact ممکن است مفید باشد. اما اگر BasePage شامل Click، Type، Scroll، API، Database، Retry و Assertionهای عمومی شود، به God object تبدیل می‌شود. Composition و Helperهای هدفمند معمولاً تغییر را بهتر محصور می‌کنند.

Flow/Task؛ Journey چندصفحه‌ای را کجا بگذاریم؟

Checkout ممکن است Login، Cart، Address، Payment و Confirmation را طی کند. قرار دادن کل Journey در CheckoutPage مرز صفحه را می‌شکند. یک Task لایه بالاتر بسازید:

export async function placeOrder(
  app: AppPages,
  order: TestOrder
): Promise<string> {
  await app.cart.add(order.productId, order.quantity);
  await app.checkout.setAddress(order.address);
  await app.checkout.choosePayment('sandbox');
  return await app.checkout.submitAndGetOrderId();
}

Task قصد کسب‌وکار را بیان می‌کند و از Page/Component استفاده می‌کند. Test همچنان Assertion و Variation را کنترل می‌کند. برای هر Journey بزرگ Task نسازید؛ فقط تکرار و مرز روشن را استخراج کنید.

POM در Selenium، Playwright و Cypress

Selenium

POM در مستند رسمی Selenium یک الگوی Encouraged است. Page services، Component objects و عدم Assertion کسب‌وکار از نکات اصلی‌اند. در پروژه جدید، از مثال‌های قدیمی PageFactory صرفاً به دلیل شهرت استفاده نکنید؛ نیاز، زبان، Binding و مستند جاری را بررسی کنید. آموزش شروع فعلی در اولین تست Selenium با Python موجود است.

Playwright

مستند Playwright POM را برای متمرکزکردن Selector و ساخت API سطح بالاتر نشان می‌دهد. Locator، Fixture، Auto-wait و Trace روی طراحی اثر دارند؛ الگوی Selenium را خط‌به‌خط منتقل نکنید.

Cypress

مستند Best Practices رسمی Cypress بر Selector مقاوم، کنترل State و استقلال تست تأکید دارد. همچنین تیم Cypress در مقاله رسمی Application Actions استدلال می‌کند که برای بعضی پروژه‌ها Actionهای نزدیک‌تر به برنامه، از Page Object کلاسیک ساده‌تر و سریع‌ترند.

این به معنی ممنوع‌بودن POM در Cypress نیست؛ یعنی معماری Runner و محصول را در تصمیم وارد کنید. Custom commands، functions، app actions یا component abstractions ممکن است مرز طبیعی‌تری باشند.

چه زمانی POM انتخاب مناسبی نیست؟

  • Suite بسیار کوچک است و تکرار واقعی ندارد؛
  • بخش عمده رفتار را می‌توان در API/Component سریع‌تر تست کرد؛
  • UI آن‌قدر آزمایشی است که Interface صفحه هنوز پایدار نیست؛
  • تیم به‌جای مدل خدمت، فقط Selectorها را در کلاس جابه‌جا می‌کند؛
  • Framework الگوی Function/Fixture/Action ساده‌تری ارائه می‌دهد؛
  • مشکل اصلی داده، Environment، Dependency یا Oracle است؛
  • هزینه Abstraction از تغییر موردانتظار بیشتر است.

برای انتخاب معماری، Must-have و PoC کوچک را با راهنمای انتخاب Framework اتوماسیون انجام دهید.

Anti-patternهای رایج POM

Page Object یک‌به‌یک با URL

Single-page app ممکن است چند State/Component در یک Route داشته باشد؛ برعکس، یک خدمت ممکن است بین Routeها مشترک باشد. مرز را بر اساس مسئولیت و تغییر انتخاب کنید.

متدهای سطح پایین بی‌شمار

clickEmail()، typeOneCharacter() و getRawElement() قصد را پنهان می‌کنند. متد باید عمل معنادار یا دسترسی لازم برای Oracle را ارائه دهد.

Workflow و Assertion داخل Page

Page‌ای که خرید کامل را انجام و صحت همه چیز را Assert می‌کند، Testهای منفی و Variation را دشوار می‌سازد. Flow و Test را جدا کنید.

State مشترک و وابستگی بین تست‌ها

POM نباید Login یا Cart یک Test را برای Test بعدی نگه دارد. هر تست داده و Browser context مستقل یا Setup صریح داشته باشد.

Locator «هوشمند» بدون قرارداد

Fallbackهای متعدد یا AI self-healing می‌توانند عنصر اشتباه را انتخاب کنند. هر Healing باید قابل‌ردیابی و برای عملیات حساس تأیید شود.

انتزاع زودهنگام

با یک Journey عمودی شروع کنید. وقتی دومین یا سومین استفاده و الگوی تغییر دیده شد، Abstraction را استخراج کنید.

مهاجرت از Scriptهای خام به POM

گام ۱: خط مبنا

Flaky rate، p95 زمان اجرا، Failهای غیرقابل‌تشخیص و زمان تغییر یک Selector پرتکرار را ثبت کنید. بدون Baseline نمی‌دانید POM ارزش ساخته است یا فقط فایل‌ها را جابه‌جا کرده است.

گام ۲: سیاست Locator و Failure artifact

پیش از کلاس‌سازی، قرارداد Selector، Wait و Trace/Screenshot/Log را تعیین کنید.

گام ۳: یک Vertical slice

یک Journey مهم اما محدود مانند Login را با Page، Component لازم، Fixture داده و دو Scenario موفق/ناموفق بازنویسی کنید.

گام ۴: Review مرزها

بپرسید Test قصد را می‌گوید؟ Page فقط UI را می‌شناسد؟ Oracle روشن است؟ Failure قابل‌تشخیص است؟

گام ۵: مهاجرت بر اساس تغییر

همه Suite را یک‌جا بازنویسی نکنید. هنگام تغییر Feature یا رفع Flaky، ناحیه مرتبط را منتقل کنید؛ تست Legacy قابل‌اعتماد را بی‌دلیل دست نزنید.

گام ۶: اندازه‌گیری و توقف

Change amplification، Flaky، زمان بازخورد و بار نگهداری را بسنجید. اگر لایه ارزش نمی‌سازد، ساده‌سازی کنید. معیارهای سالم در راهنمای متریک‌های تست آمده‌اند.

ساختار پوشه پیشنهادی

tests/
  checkout.spec.ts
  login.spec.ts
pages/
  login-page.ts
  checkout-page.ts
components/
  header.ts
  product-card.ts
flows/
  place-order.ts
fixtures/
  app-pages.ts
data/
  builders.ts
support/
  artifacts.ts

ساختار را بر اساس Ownership و Import direction نگه دارید؛ نام پوشه به‌تنهایی معماری ایجاد نمی‌کند. Cycleهای Import و Fixture جادویی نشانه مرز نامناسب‌اند.

مثال ایران‌محور: Checkout فارسی و RTL

  • برای Label و Role فارسی، Unicode و نیم‌فاصله را آگاهانه تست کنید؛
  • Locator متن‌محور را با تست ترجمه اشتباه نگیرید؛
  • اعداد فارسی/لاتین، مبلغ ریال/تومان و Round باید Oracle مستقل داشته باشند؛
  • فونت خارجی ناپایدار می‌تواند Visual test را مختل کند؛ Asset کنترل‌شده داشته باشید؛
  • درگاه Sandbox را با Stub قراردادی تکمیل، نه جایگزین کامل، کنید؛
  • Token، شماره موبایل و داده بانکی در Trace/Screenshot Mask شوند؛
  • برای اختلال شبکه، State و Retry را تست کنید؛ Sleep طولانی راه‌حل نیست.

چک‌لیست Code Review برای POM

  • آیا Abstraction تکرار یا تغییر واقعی را محصور می‌کند؟
  • نام متد خدمت/قصد را می‌گوید، نه فقط Click؟
  • Locator Semantic یا قرارداد پایدار دارد؟
  • Wait روی State قابل‌مشاهده است و Sleep ثابت ندارد؟
  • Assertion کسب‌وکار در Test خواناست؟
  • Guard صفحه Failure اصلی را مبهم نمی‌کند؟
  • Component مشترک با Composition جدا شده است؟
  • Journey چندصفحه‌ای از Page بیرون مانده است؟
  • Test data و Secret داخل Page نیستند؟
  • تست‌ها مستقل و قابل اجرای موازی‌اند؟
  • Fail، Trace/Log/Screenshot امن و قابل‌اقدام دارد؟
  • Page Object از نیاز پروژه بزرگ‌تر نشده است؟

پرسش‌های متداول

آیا POM باعث حذف Flaky test می‌شود؟

خیر. POM می‌تواند Locator و Wait درست را متمرکز کند، اما داده مشترک، Environment، Race و Oracle ضعیف را حل نمی‌کند. علت Flaky باید جدا Triage شود.

آیا Assertion باید همیشه بیرون Page Object باشد؟

Assertion رفتار تحت تست معمولاً در Spec می‌ماند. Guard آماده‌بودن صفحه یا Invariant فنی محدود می‌تواند داخل Page باشد، به شرط اینکه هدف Test و Failure report را پنهان نکند.

برای هر صفحه یک کلاس لازم است؟

خیر. مرز بر اساس مسئولیت و تغییر است. یک صفحه بزرگ چند Component Object و چند Route ساده یک abstraction مشترک می‌توانند داشته باشند.

Page Object بهتر است Locator برگرداند یا مقدار؟

به Framework و قرارداد تیم بستگی دارد. در Playwright، Locator عمومی محدود برای Assertionهای Retry شونده رایج است؛ در سبک‌های دیگر Getter مقدار مناسب است. Raw Selector/Driver را بی‌دلیل افشا نکنید.

POM بهتر است یا Screenplay/Application Actions؟

هیچ برنده عمومی وجود ندارد. اندازه Suite، Framework، نوع Journey، مهارت تیم و الگوی تغییر تعیین‌کننده‌اند. یک PoC کوچک را با خوانایی، تشخیص Fail و هزینه تغییر مقایسه کنید.

جمع‌بندی

POM زمانی مفید است که دانش UI را در مرزی کوچک و معنادار محصور کند. Page و Component باید خدمات رابط را مدل کنند؛ Flow قصد چندصفحه‌ای و Test، Oracle را نگه دارد. Locator پایدار، Wait متناسب با Framework، داده مستقل و Artifact تشخیصی مهم‌تر از داشتن پوشه‌ای به نام pages هستند. از یک Journey شروع کنید، اثر را اندازه بگیرید و فقط به اندازه مسئله Abstraction بسازید.

دیدگاهتان را بنویسید